backend-manager 5.2.5 → 5.2.7
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/CHANGELOG.md +40 -0
- package/TODO-CANCEL-EMAIL-MISSING-ORDER-ID.md +159 -0
- package/TODO-WEBHOOK-KEY-LEGACY-REMOVAL.md +15 -0
- package/TODO-WEBHOOK-KEY-UPGRADE.md +138 -0
- package/docs/stripe-webhook-forwarding.md +2 -2
- package/package.json +1 -1
- package/scripts/test-helper-providers.js +162 -0
- package/src/cli/commands/base-command.js +5 -5
- package/src/cli/commands/emulator.js +201 -54
- package/src/cli/commands/test.js +80 -9
- package/src/manager/events/cron/daily/ghostii-auto-publisher.js +2 -2
- package/src/manager/events/firestore/payments-webhooks/analytics.js +2 -2
- package/src/manager/functions/core/actions/api/user/delete.js +1 -1
- package/src/manager/helpers/analytics.js +1 -1
- package/src/manager/libraries/email/generators/newsletter.js +2 -2
- package/src/manager/libraries/email/providers/beehiiv.js +11 -6
- package/src/manager/libraries/email/providers/sendgrid.js +6 -6
- package/src/manager/libraries/email/validation.js +1 -1
- package/src/manager/libraries/infer-contact.js +12 -5
- package/src/manager/routes/general/email/post.js +4 -2
- package/src/manager/routes/marketing/email-preferences/post.js +2 -2
- package/src/manager/routes/payments/dispute-alert/post.js +3 -3
- package/src/manager/routes/payments/intent/processors/test.js +2 -2
- package/src/manager/routes/payments/webhook/post.js +2 -2
- package/src/manager/routes/user/delete.js +1 -1
- package/src/manager/routes/user/oauth2/providers/discord.js +1 -1
- package/src/manager/routes/user/oauth2/providers/google.js +1 -1
- package/src/manager/routes/user/signup/post.js +1 -0
- package/src/test/runner.js +7 -0
- package/src/test/utils/http-client.js +1 -0
- package/test/events/payments/journey-payments-cancel.js +4 -4
- package/test/events/payments/journey-payments-failure.js +2 -2
- package/test/events/payments/journey-payments-legacy-product.js +1 -1
- package/test/events/payments/journey-payments-one-time-failure.js +1 -1
- package/test/events/payments/journey-payments-plan-change.js +1 -1
- package/test/events/payments/journey-payments-refund-webhook.js +4 -4
- package/test/events/payments/journey-payments-suspend.js +4 -4
- package/test/events/payments/journey-payments-trial.js +2 -2
- package/test/events/payments/journey-payments-uid-resolution.js +1 -1
- package/test/routes/payments/dispute-alert.js +13 -13
- package/test/routes/payments/webhook.js +3 -3
- package/test/routes/test/usage.js +12 -3
- /package/src/manager/routes/general/email/templates/{download-app-link.js → general/download-app-link.js} +0 -0
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
const BaseCommand = require('./base-command');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const fs = require('fs');
|
|
4
|
+
const { spawn } = require('child_process');
|
|
4
5
|
const chalk = require('chalk').default;
|
|
5
6
|
const jetpack = require('fs-jetpack');
|
|
6
7
|
const JSON5 = require('json5');
|
|
7
|
-
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
11
|
const { writeTestMode, captureSyncedEnv } = require('../../test/utils/test-mode-file');
|
|
12
12
|
|
|
13
|
+
// Used by both `npx mgr emulator` and `npx mgr test` auto-start path.
|
|
14
|
+
// Note: `emulators:start` enables the UI by default (controlled by firebase.json's
|
|
15
|
+
// `emulators.ui.enabled`), so no `--ui` flag here — that flag only exists on `:exec`.
|
|
16
|
+
const EMULATOR_FLAGS = '--only functions,firestore,auth,database,hosting,pubsub';
|
|
17
|
+
|
|
13
18
|
class EmulatorCommand extends BaseCommand {
|
|
14
19
|
async execute() {
|
|
15
20
|
this.log(chalk.cyan('\n Starting Firebase emulator (keep-alive mode)...\n'));
|
|
@@ -45,23 +50,40 @@ class EmulatorCommand extends BaseCommand {
|
|
|
45
50
|
// Start Stripe webhook forwarding in background
|
|
46
51
|
this.startStripeWebhookForwarding();
|
|
47
52
|
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
53
|
+
// Keep-alive: boot emulators and wait for Ctrl+C. No "command" subprocess —
|
|
54
|
+
// the emulator child IS the foreground process from the user's perspective.
|
|
51
55
|
try {
|
|
52
|
-
await this.
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
const { shutdown, exitPromise } = await this.startEmulators();
|
|
57
|
+
|
|
58
|
+
this.log(chalk.gray('\n Emulator ready. Press Ctrl+C to shut down...\n'));
|
|
59
|
+
|
|
60
|
+
const onSigint = async () => {
|
|
61
|
+
this.log(chalk.gray('\n Shutting down emulator...'));
|
|
62
|
+
await shutdown();
|
|
63
|
+
this.log(chalk.gray(' Emulator stopped.\n'));
|
|
64
|
+
process.exit(0);
|
|
65
|
+
};
|
|
66
|
+
process.once('SIGINT', onSigint);
|
|
67
|
+
|
|
68
|
+
// Resolve if the emulator dies on its own (crash, port conflict, etc.)
|
|
69
|
+
await exitPromise;
|
|
70
|
+
process.removeListener('SIGINT', onSigint);
|
|
55
71
|
this.log(chalk.gray('\n Emulator stopped.\n'));
|
|
72
|
+
} catch (error) {
|
|
73
|
+
this.logError(`Emulator error: ${error.message || error}`);
|
|
74
|
+
process.exit(1);
|
|
56
75
|
}
|
|
57
76
|
}
|
|
58
77
|
|
|
59
78
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
79
|
+
* Boot Firebase emulators as a long-running child process.
|
|
80
|
+
* Stdout/stderr are teed to console + emulator.log.
|
|
81
|
+
* Resolves once the emulator hub is listening (i.e., emulators are ready).
|
|
82
|
+
* Caller is responsible for calling shutdown() to send SIGTERM and wait for exit.
|
|
83
|
+
*
|
|
84
|
+
* @returns {Promise<{ child: ChildProcess, shutdown: () => Promise<void>, emulatorPorts: object }>}
|
|
63
85
|
*/
|
|
64
|
-
async
|
|
86
|
+
async startEmulators() {
|
|
65
87
|
const projectDir = this.main.firebaseProjectPath;
|
|
66
88
|
|
|
67
89
|
// Load emulator ports from firebase.json
|
|
@@ -73,24 +95,13 @@ class EmulatorCommand extends BaseCommand {
|
|
|
73
95
|
throw new Error('Port conflicts could not be resolved');
|
|
74
96
|
}
|
|
75
97
|
|
|
76
|
-
// Wipe stale firebase-tools debug logs + any leftover BEM logs from older
|
|
77
|
-
// versions. Keeps the project tree clean across runs.
|
|
98
|
+
// Wipe stale firebase-tools debug logs + any leftover BEM logs from older versions.
|
|
78
99
|
this.sweepStaleLogs();
|
|
79
100
|
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
const envPrefix = process.env.TEST_EXTENDED_MODE
|
|
85
|
-
? 'BEM_TESTING=true TEST_EXTENDED_MODE=true'
|
|
86
|
-
: 'BEM_TESTING=true';
|
|
87
|
-
const emulatorCommand = `${envPrefix} firebase emulators:exec --only functions,firestore,auth,database,hosting,pubsub --ui "${command}"`;
|
|
88
|
-
|
|
89
|
-
// Set up log file in the project directory.
|
|
90
|
-
// We use a mutable `currentStream` so the test command can request a fresh log
|
|
91
|
-
// by touching emulator.log.reset — the watcher below detects it, closes the
|
|
92
|
-
// current stream, reopens with flags: 'w' (truncating cleanly from our process'
|
|
93
|
-
// perspective, no sparse-file issue), and deletes the sentinel.
|
|
101
|
+
// Set up log file + reset-sentinel watcher.
|
|
102
|
+
// Mutable `currentStream` so the test command can request a fresh log by touching
|
|
103
|
+
// emulator.log.reset — the watcher detects it, closes the current stream, and
|
|
104
|
+
// reopens with flags: 'w' (truncating cleanly from our process' perspective).
|
|
94
105
|
const logPath = this.getLogsPath('emulator.log');
|
|
95
106
|
const resetSentinelPath = this.getTempPath('emulator.log.reset');
|
|
96
107
|
const stripAnsi = (str) => str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
|
|
@@ -103,11 +114,9 @@ class EmulatorCommand extends BaseCommand {
|
|
|
103
114
|
}
|
|
104
115
|
}
|
|
105
116
|
|
|
106
|
-
// Clean up any stale sentinel from a prior crashed
|
|
117
|
+
// Clean up any stale sentinel from a prior crashed run
|
|
107
118
|
try { fs.unlinkSync(resetSentinelPath); } catch (e) { /* not present, ok */ }
|
|
108
119
|
|
|
109
|
-
// Watch for the test command's request to roll the log.
|
|
110
|
-
// Poll every 500ms — cheap, no fs.watch quirks across platforms.
|
|
111
120
|
const resetWatcher = setInterval(() => {
|
|
112
121
|
if (!fs.existsSync(resetSentinelPath)) {
|
|
113
122
|
return;
|
|
@@ -129,37 +138,175 @@ class EmulatorCommand extends BaseCommand {
|
|
|
129
138
|
writeToLog('\n');
|
|
130
139
|
}
|
|
131
140
|
|
|
132
|
-
this.log(chalk.gray(` Logs saving to: ${logPath}
|
|
141
|
+
this.log(chalk.gray(` Logs saving to: ${logPath}`));
|
|
142
|
+
|
|
143
|
+
// BEM_TESTING=true is passed so Functions skip external API calls (emails, SendGrid)
|
|
144
|
+
// hosting is included so localhost:5002 rewrites work (e.g., /backend-manager -> bm_api)
|
|
145
|
+
// pubsub is included so scheduled functions (bm_cronDaily) can be triggered in tests
|
|
146
|
+
const env = {
|
|
147
|
+
...process.env,
|
|
148
|
+
FORCE_COLOR: '1',
|
|
149
|
+
BEM_TESTING: 'true',
|
|
150
|
+
};
|
|
133
151
|
|
|
134
|
-
|
|
135
|
-
|
|
152
|
+
// Spawn `firebase emulators:start` as a background child. Use `sh -c` so the
|
|
153
|
+
// user's shell PATH resolves `firebase` consistently with the interactive shell.
|
|
154
|
+
//
|
|
155
|
+
// `detached: true` puts the child into its own process group. We need this so that
|
|
156
|
+
// shutdown() can kill the entire group (sh → firebase → java emulators) by
|
|
157
|
+
// signalling the negative pgid. Without it, SIGTERM to the shell doesn't propagate
|
|
158
|
+
// to firebase or its java grandchildren, leaving orphan firestore/pubsub processes.
|
|
159
|
+
const child = spawn('sh', ['-c', `firebase emulators:start ${EMULATOR_FLAGS}`], {
|
|
136
160
|
cwd: projectDir,
|
|
137
|
-
|
|
138
|
-
|
|
161
|
+
env,
|
|
162
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
163
|
+
detached: true,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Wire readiness detection into the stdout/stderr handlers.
|
|
167
|
+
//
|
|
168
|
+
// We watch for firebase-tools' explicit "All emulators ready!" line — that's the
|
|
169
|
+
// signal that function discovery + load is complete and the runtime can serve HTTP.
|
|
170
|
+
// Port-listening alone isn't enough: firebase-tools binds the functions socket
|
|
171
|
+
// ~5-10s before user functions are actually loadable, so HTTP requests fail with
|
|
172
|
+
// ECONNREFUSED / "fetch failed" if we proceed when only the port is open.
|
|
173
|
+
let readyResolve;
|
|
174
|
+
let readyReject;
|
|
175
|
+
const readyPromise = new Promise((resolve, reject) => {
|
|
176
|
+
readyResolve = resolve;
|
|
177
|
+
readyReject = reject;
|
|
178
|
+
});
|
|
179
|
+
let ready = false;
|
|
180
|
+
const READY_MARKER = /All emulators ready/i;
|
|
181
|
+
|
|
182
|
+
child.stdout.on('data', (data) => {
|
|
183
|
+
process.stdout.write(data);
|
|
184
|
+
writeToLog(data);
|
|
185
|
+
if (!ready && READY_MARKER.test(data.toString())) {
|
|
186
|
+
ready = true;
|
|
187
|
+
readyResolve();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
child.stderr.on('data', (data) => {
|
|
192
|
+
process.stderr.write(data);
|
|
193
|
+
writeToLog(data);
|
|
194
|
+
// firebase-tools prints the ready line to stderr sometimes — watch both.
|
|
195
|
+
if (!ready && READY_MARKER.test(data.toString())) {
|
|
196
|
+
ready = true;
|
|
197
|
+
readyResolve();
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Track exit state so shutdown() can resolve when the process is gone
|
|
202
|
+
let exitPromiseResolve;
|
|
203
|
+
const exitPromise = new Promise((resolve) => {
|
|
204
|
+
exitPromiseResolve = resolve;
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
child.on('close', (code, signal) => {
|
|
208
|
+
clearInterval(resetWatcher);
|
|
209
|
+
if (currentStream && !currentStream.destroyed) {
|
|
210
|
+
currentStream.end();
|
|
211
|
+
}
|
|
212
|
+
try { fs.unlinkSync(resetSentinelPath); } catch (e) { /* ok */ }
|
|
213
|
+
exitPromiseResolve({ code, signal });
|
|
214
|
+
// If we exited before becoming ready, fail the readiness wait too
|
|
215
|
+
if (!ready) {
|
|
216
|
+
readyReject(new Error(`Emulator child exited before ready (code=${code}, signal=${signal})`));
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// Race the readiness marker against a 60s timeout
|
|
221
|
+
const readyTimeoutMs = 60000;
|
|
222
|
+
await Promise.race([
|
|
223
|
+
readyPromise,
|
|
224
|
+
new Promise((_, reject) => setTimeout(
|
|
225
|
+
() => reject(new Error(`Emulator did not print "All emulators ready" within ${readyTimeoutMs}ms`)),
|
|
226
|
+
readyTimeoutMs,
|
|
227
|
+
)),
|
|
228
|
+
]);
|
|
229
|
+
|
|
230
|
+
// shutdown() signals the entire emulator process group (sh + firebase + java
|
|
231
|
+
// grandchildren), waits up to 10s for clean exit, then escalates to SIGKILL.
|
|
232
|
+
//
|
|
233
|
+
// We use `process.kill(-pgid, ...)` instead of `child.kill(...)` because firebase
|
|
234
|
+
// tools spawns several Java subprocesses (firestore + pubsub) that survive if
|
|
235
|
+
// only the sh wrapper is killed. The negative PID targets the whole process group
|
|
236
|
+
// (made possible by `detached: true` above).
|
|
237
|
+
const killGroup = (signal) => {
|
|
238
|
+
try {
|
|
239
|
+
process.kill(-child.pid, signal);
|
|
240
|
+
} catch (e) {
|
|
241
|
+
// ESRCH = group already dead, that's fine
|
|
242
|
+
if (e.code !== 'ESRCH') throw e;
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const shutdown = async () => {
|
|
247
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
248
|
+
return; // already gone
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
killGroup('SIGTERM');
|
|
252
|
+
|
|
253
|
+
const killTimer = setTimeout(() => {
|
|
254
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
255
|
+
killGroup('SIGKILL');
|
|
256
|
+
}
|
|
257
|
+
}, 10000);
|
|
258
|
+
|
|
259
|
+
await exitPromise;
|
|
260
|
+
clearTimeout(killTimer);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
return { child, shutdown, emulatorPorts, exitPromise };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Boot emulators and run a single command against them. Sends SIGTERM to the emulator
|
|
268
|
+
* when the command exits (or this process is interrupted) and waits for clean shutdown.
|
|
269
|
+
*
|
|
270
|
+
* Used by `npx mgr emulator` for the keep-alive flow (command is a no-op sleep).
|
|
271
|
+
* `npx mgr test`'s auto-start path uses startEmulators() directly so it can tee the
|
|
272
|
+
* test command's output to its own log (test.log) separate from emulator.log.
|
|
273
|
+
*
|
|
274
|
+
* @param {string} command - shell command to run while emulators are up
|
|
275
|
+
*/
|
|
276
|
+
async runWithEmulator(command) {
|
|
277
|
+
const { shutdown, exitPromise } = await this.startEmulators();
|
|
278
|
+
|
|
279
|
+
// SIGINT (Ctrl+C) → graceful shutdown
|
|
280
|
+
const onSigint = async () => {
|
|
281
|
+
await shutdown();
|
|
282
|
+
process.exit(0);
|
|
283
|
+
};
|
|
284
|
+
process.once('SIGINT', onSigint);
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
// Run the user command; when it exits we tear down the emulator.
|
|
288
|
+
const cmdChild = spawn('sh', ['-c', command], {
|
|
289
|
+
cwd: this.main.firebaseProjectPath,
|
|
139
290
|
env: { ...process.env, FORCE_COLOR: '1' },
|
|
140
|
-
|
|
141
|
-
}, (child) => {
|
|
142
|
-
// Tee stdout to both console and log file (strip ANSI codes for clean log)
|
|
143
|
-
child.stdout.on('data', (data) => {
|
|
144
|
-
process.stdout.write(data);
|
|
145
|
-
writeToLog(data);
|
|
291
|
+
stdio: 'inherit',
|
|
146
292
|
});
|
|
147
293
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
process.stderr.write(data);
|
|
151
|
-
writeToLog(data);
|
|
294
|
+
const cmdExit = await new Promise((resolve) => {
|
|
295
|
+
cmdChild.on('close', (code, signal) => resolve({ code, signal }));
|
|
152
296
|
});
|
|
153
297
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
298
|
+
process.removeListener('SIGINT', onSigint);
|
|
299
|
+
await shutdown();
|
|
300
|
+
await exitPromise;
|
|
301
|
+
|
|
302
|
+
if (cmdExit.code !== 0) {
|
|
303
|
+
throw Object.assign(new Error(`Command exited with code ${cmdExit.code}`), { code: cmdExit.code });
|
|
304
|
+
}
|
|
305
|
+
} catch (e) {
|
|
306
|
+
process.removeListener('SIGINT', onSigint);
|
|
307
|
+
await shutdown();
|
|
308
|
+
throw e;
|
|
309
|
+
}
|
|
163
310
|
}
|
|
164
311
|
|
|
165
312
|
/**
|
package/src/cli/commands/test.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const BaseCommand = require('./base-command');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const fs = require('fs');
|
|
4
|
+
const { spawn } = require('child_process');
|
|
4
5
|
const chalk = require('chalk').default;
|
|
5
6
|
const jetpack = require('fs-jetpack');
|
|
6
7
|
const JSON5 = require('json5');
|
|
@@ -69,7 +70,7 @@ class TestCommand extends BaseCommand {
|
|
|
69
70
|
await this.runTestsDirectly(testCommand, functionsDir, emulatorPorts);
|
|
70
71
|
} else {
|
|
71
72
|
this.log(chalk.cyan('Starting emulator and running tests...'));
|
|
72
|
-
await this.runEmulatorTests(testCommand);
|
|
73
|
+
await this.runEmulatorTests(testCommand, functionsDir);
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
76
|
|
|
@@ -123,6 +124,7 @@ class TestCommand extends BaseCommand {
|
|
|
123
124
|
|
|
124
125
|
// Derive computed values (not in config file)
|
|
125
126
|
const backendManagerKey = argv.key || process.env.BACKEND_MANAGER_KEY;
|
|
127
|
+
const backendManagerWebhookKey = argv.webhookKey || process.env.BACKEND_MANAGER_WEBHOOK_KEY;
|
|
126
128
|
const contactEmail = config.brand?.contact?.email || '';
|
|
127
129
|
const domain = contactEmail.includes('@') ? contactEmail.split('@')[1] : '';
|
|
128
130
|
|
|
@@ -138,6 +140,12 @@ class TestCommand extends BaseCommand {
|
|
|
138
140
|
return null;
|
|
139
141
|
}
|
|
140
142
|
|
|
143
|
+
if (!backendManagerWebhookKey) {
|
|
144
|
+
this.logError('Error: Missing backend manager webhook key');
|
|
145
|
+
this.log(chalk.gray(' Set BACKEND_MANAGER_WEBHOOK_KEY in your .env file or pass --webhook-key flag'));
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
141
149
|
if (!config.brand?.id) {
|
|
142
150
|
this.logError('Error: Missing brand.id in backend-manager-config.json');
|
|
143
151
|
return null;
|
|
@@ -152,6 +160,7 @@ class TestCommand extends BaseCommand {
|
|
|
152
160
|
return {
|
|
153
161
|
...config,
|
|
154
162
|
backendManagerKey,
|
|
163
|
+
backendManagerWebhookKey,
|
|
155
164
|
domain,
|
|
156
165
|
};
|
|
157
166
|
}
|
|
@@ -280,23 +289,85 @@ class TestCommand extends BaseCommand {
|
|
|
280
289
|
}
|
|
281
290
|
|
|
282
291
|
/**
|
|
283
|
-
* Run tests with Firebase emulator (starts emulator, runs tests, shuts down)
|
|
292
|
+
* Run tests with Firebase emulator (starts emulator, runs tests, shuts down).
|
|
293
|
+
*
|
|
294
|
+
* Two real child processes are used so emulator output and test-runner output
|
|
295
|
+
* land in separate log files:
|
|
296
|
+
* - `emulator.log` — `firebase emulators:start` stdout/stderr (managed by EmulatorCommand)
|
|
297
|
+
* - `test.log` — the test-runner subprocess stdout/stderr (managed here)
|
|
284
298
|
*/
|
|
285
|
-
async runEmulatorTests(testCommand) {
|
|
299
|
+
async runEmulatorTests(testCommand, functionsDir) {
|
|
286
300
|
this.log(chalk.gray(' Starting Firebase emulator...\n'));
|
|
287
301
|
|
|
288
|
-
// Use EmulatorCommand to run tests with emulator
|
|
289
302
|
const emulatorCmd = new EmulatorCommand(this.main);
|
|
303
|
+
let started;
|
|
290
304
|
|
|
291
305
|
try {
|
|
292
|
-
await emulatorCmd.
|
|
306
|
+
started = await emulatorCmd.startEmulators();
|
|
293
307
|
} catch (error) {
|
|
294
|
-
|
|
295
|
-
if (error.code !== 0) {
|
|
296
|
-
this.logError(`Emulator error: ${error.message || error}`);
|
|
297
|
-
}
|
|
308
|
+
this.logError(`Emulator error: ${error.message || error}`);
|
|
298
309
|
process.exit(1);
|
|
299
310
|
}
|
|
311
|
+
|
|
312
|
+
const { shutdown, exitPromise, emulatorPorts } = started;
|
|
313
|
+
|
|
314
|
+
// Forward Ctrl+C to a clean emulator shutdown
|
|
315
|
+
const onSigint = async () => {
|
|
316
|
+
this.log(chalk.gray('\n Shutting down emulator...'));
|
|
317
|
+
await shutdown();
|
|
318
|
+
process.exit(130);
|
|
319
|
+
};
|
|
320
|
+
process.once('SIGINT', onSigint);
|
|
321
|
+
|
|
322
|
+
// Print the same connection summary the existing-emulator path shows
|
|
323
|
+
this.log('');
|
|
324
|
+
this.log(chalk.gray(` Hosting: http://127.0.0.1:${emulatorPorts.hosting}`));
|
|
325
|
+
this.log(chalk.gray(` Firestore: 127.0.0.1:${emulatorPorts.firestore}`));
|
|
326
|
+
this.log(chalk.gray(` Auth: 127.0.0.1:${emulatorPorts.auth}`));
|
|
327
|
+
this.log(chalk.gray(` UI: http://127.0.0.1:${emulatorPorts.ui}`));
|
|
328
|
+
|
|
329
|
+
// Spawn the test runner as its own child so its stdout/stderr can be teed to test.log
|
|
330
|
+
const logPath = this.getLogsPath('test.log');
|
|
331
|
+
const logStream = fs.createWriteStream(logPath, { flags: 'w' });
|
|
332
|
+
const stripAnsi = (str) => str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
|
|
333
|
+
|
|
334
|
+
this.log(chalk.gray(` Logs saving to: ${logPath}\n`));
|
|
335
|
+
|
|
336
|
+
let testExitCode = 0;
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
const testChild = spawn('sh', ['-c', testCommand], {
|
|
340
|
+
cwd: functionsDir,
|
|
341
|
+
env: { ...process.env, FORCE_COLOR: '1' },
|
|
342
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
testChild.stdout.on('data', (data) => {
|
|
346
|
+
process.stdout.write(data);
|
|
347
|
+
if (!logStream.destroyed) logStream.write(stripAnsi(data.toString()));
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
testChild.stderr.on('data', (data) => {
|
|
351
|
+
process.stderr.write(data);
|
|
352
|
+
if (!logStream.destroyed) logStream.write(stripAnsi(data.toString()));
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
testExitCode = await new Promise((resolve) => {
|
|
356
|
+
testChild.on('close', (code) => {
|
|
357
|
+
if (!logStream.destroyed) logStream.end();
|
|
358
|
+
resolve(code ?? 1);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
} catch (error) {
|
|
362
|
+
this.logError(`Test runner error: ${error.message || error}`);
|
|
363
|
+
testExitCode = 1;
|
|
364
|
+
} finally {
|
|
365
|
+
process.removeListener('SIGINT', onSigint);
|
|
366
|
+
await shutdown();
|
|
367
|
+
await exitPromise;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
process.exit(testExitCode);
|
|
300
371
|
}
|
|
301
372
|
}
|
|
302
373
|
|
|
@@ -103,7 +103,7 @@ function buildBrandConfig(config) {
|
|
|
103
103
|
*/
|
|
104
104
|
function fetchRemoteBrand(brandUrl) {
|
|
105
105
|
return fetch(`${brandUrl}/backend-manager/brand`, {
|
|
106
|
-
timeout:
|
|
106
|
+
timeout: 120000,
|
|
107
107
|
tries: 3,
|
|
108
108
|
response: 'json',
|
|
109
109
|
});
|
|
@@ -174,7 +174,7 @@ async function harvest(assistant, settings) {
|
|
|
174
174
|
|
|
175
175
|
function getURLContent(url) {
|
|
176
176
|
return fetch(url, {
|
|
177
|
-
timeout:
|
|
177
|
+
timeout: 120000,
|
|
178
178
|
tries: 3,
|
|
179
179
|
response: 'raw',
|
|
180
180
|
headers: {
|
|
@@ -225,7 +225,7 @@ function fireMeta({ resolved, currency, uid, processor, assistant, config }) {
|
|
|
225
225
|
method: 'post',
|
|
226
226
|
response: 'json',
|
|
227
227
|
body: payload,
|
|
228
|
-
timeout:
|
|
228
|
+
timeout: 60000,
|
|
229
229
|
tries: 2,
|
|
230
230
|
})
|
|
231
231
|
.then(() => {
|
|
@@ -298,7 +298,7 @@ function fireTikTok({ resolved, currency, uid, processor, assistant, config }) {
|
|
|
298
298
|
'Access-Token': accessToken,
|
|
299
299
|
},
|
|
300
300
|
body: { data: [payload] },
|
|
301
|
-
timeout:
|
|
301
|
+
timeout: 60000,
|
|
302
302
|
tries: 2,
|
|
303
303
|
})
|
|
304
304
|
.then(() => {
|
|
@@ -517,7 +517,7 @@ async function fetchSources(parentUrl, categories, brandId, assistant) {
|
|
|
517
517
|
const data = await fetch(`${parentUrl}/newsletter-sources`, {
|
|
518
518
|
method: 'get',
|
|
519
519
|
response: 'json',
|
|
520
|
-
timeout:
|
|
520
|
+
timeout: 60000,
|
|
521
521
|
query: {
|
|
522
522
|
category,
|
|
523
523
|
limit: 3,
|
|
@@ -546,7 +546,7 @@ async function claimSources(parentUrl, sources, brandId, assistant) {
|
|
|
546
546
|
await fetch(`${parentUrl}/newsletter-sources`, {
|
|
547
547
|
method: 'put',
|
|
548
548
|
response: 'json',
|
|
549
|
-
timeout:
|
|
549
|
+
timeout: 60000,
|
|
550
550
|
body: {
|
|
551
551
|
id: source.id,
|
|
552
552
|
usedBy: brandId || 'unknown',
|
|
@@ -9,6 +9,11 @@ const { FIELDS, resolveFieldValues } = require('../constants.js');
|
|
|
9
9
|
|
|
10
10
|
const BASE_URL = 'https://api.beehiiv.com/v2';
|
|
11
11
|
|
|
12
|
+
// Beehiiv API spikes past 10s during their hiccups, dropping signups silently.
|
|
13
|
+
// 60s is generous but harmless — caches are in place for metadata calls so a
|
|
14
|
+
// slow first call costs nothing in steady state.
|
|
15
|
+
const BEEHIIV_TIMEOUT_MS = 60000;
|
|
16
|
+
|
|
12
17
|
// --- Internal helpers ---
|
|
13
18
|
|
|
14
19
|
function headers() {
|
|
@@ -63,7 +68,7 @@ async function addSubscriber({ email, firstName, lastName, source, publicationId
|
|
|
63
68
|
method: 'post',
|
|
64
69
|
response: 'json',
|
|
65
70
|
headers: headers(),
|
|
66
|
-
timeout:
|
|
71
|
+
timeout: BEEHIIV_TIMEOUT_MS,
|
|
67
72
|
body,
|
|
68
73
|
});
|
|
69
74
|
|
|
@@ -97,7 +102,7 @@ async function findSubscriber(email, publicationId) {
|
|
|
97
102
|
{
|
|
98
103
|
response: 'json',
|
|
99
104
|
headers: headers(),
|
|
100
|
-
timeout:
|
|
105
|
+
timeout: BEEHIIV_TIMEOUT_MS,
|
|
101
106
|
}
|
|
102
107
|
);
|
|
103
108
|
|
|
@@ -135,7 +140,7 @@ async function removeSubscriber(email, publicationId) {
|
|
|
135
140
|
{
|
|
136
141
|
method: 'delete',
|
|
137
142
|
headers: headers(),
|
|
138
|
-
timeout:
|
|
143
|
+
timeout: BEEHIIV_TIMEOUT_MS,
|
|
139
144
|
}
|
|
140
145
|
);
|
|
141
146
|
|
|
@@ -202,7 +207,7 @@ function getPublicationId() {
|
|
|
202
207
|
// const data = await fetch(`${BASE_URL}/publications?limit=${limit}&page=${page}`, {
|
|
203
208
|
// response: 'json',
|
|
204
209
|
// headers: headers(),
|
|
205
|
-
// timeout:
|
|
210
|
+
// timeout: BEEHIIV_TIMEOUT_MS,
|
|
206
211
|
// });
|
|
207
212
|
//
|
|
208
213
|
// if (!data.data || data.data.length === 0) {
|
|
@@ -351,7 +356,7 @@ async function resolveSegmentIds() {
|
|
|
351
356
|
const data = await fetch(`${BASE_URL}/publications/${publicationId}/segments?limit=100`, {
|
|
352
357
|
response: 'json',
|
|
353
358
|
headers: headers(),
|
|
354
|
-
timeout:
|
|
359
|
+
timeout: BEEHIIV_TIMEOUT_MS,
|
|
355
360
|
});
|
|
356
361
|
|
|
357
362
|
_segmentIdCache = {};
|
|
@@ -447,7 +452,7 @@ async function createPost(options) {
|
|
|
447
452
|
method: 'post',
|
|
448
453
|
response: 'json',
|
|
449
454
|
headers: headers(),
|
|
450
|
-
timeout:
|
|
455
|
+
timeout: BEEHIIV_TIMEOUT_MS,
|
|
451
456
|
body,
|
|
452
457
|
});
|
|
453
458
|
|
|
@@ -115,7 +115,7 @@ async function upsertContacts({ contacts, listIds }) {
|
|
|
115
115
|
method: 'put',
|
|
116
116
|
response: 'json',
|
|
117
117
|
headers: headers(),
|
|
118
|
-
timeout:
|
|
118
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
119
119
|
body,
|
|
120
120
|
});
|
|
121
121
|
|
|
@@ -357,7 +357,7 @@ async function createSingleSend({ name, subject, preheader, templateId, from, se
|
|
|
357
357
|
method: 'post',
|
|
358
358
|
response: 'json',
|
|
359
359
|
headers: headers(),
|
|
360
|
-
timeout:
|
|
360
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
361
361
|
body,
|
|
362
362
|
});
|
|
363
363
|
|
|
@@ -385,7 +385,7 @@ async function scheduleSingleSend(singleSendId, sendAt) {
|
|
|
385
385
|
method: 'put',
|
|
386
386
|
response: 'json',
|
|
387
387
|
headers: headers(),
|
|
388
|
-
timeout:
|
|
388
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
389
389
|
body: { send_at: sendAt },
|
|
390
390
|
});
|
|
391
391
|
|
|
@@ -545,7 +545,7 @@ async function getSegmentContacts(segmentId, maxWaitMs = 60000) {
|
|
|
545
545
|
method: 'post',
|
|
546
546
|
response: 'json',
|
|
547
547
|
headers: headers(),
|
|
548
|
-
timeout:
|
|
548
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
549
549
|
body: { segment_ids: [segmentId] },
|
|
550
550
|
});
|
|
551
551
|
|
|
@@ -575,7 +575,7 @@ async function getSegmentContacts(segmentId, maxWaitMs = 60000) {
|
|
|
575
575
|
// Download CSV — disable cacheBreaker to preserve presigned S3 URL signature
|
|
576
576
|
const csvText = await fetch(statusData.urls[0], {
|
|
577
577
|
response: 'text',
|
|
578
|
-
timeout:
|
|
578
|
+
timeout: 60000,
|
|
579
579
|
cacheBreaker: false,
|
|
580
580
|
});
|
|
581
581
|
|
|
@@ -639,7 +639,7 @@ async function bulkDeleteContacts(contactIds) {
|
|
|
639
639
|
method: 'delete',
|
|
640
640
|
response: 'json',
|
|
641
641
|
headers: headers(),
|
|
642
|
-
timeout:
|
|
642
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
643
643
|
});
|
|
644
644
|
|
|
645
645
|
if (data.job_id) {
|
|
@@ -133,7 +133,7 @@ async function validate(email, options = {}) {
|
|
|
133
133
|
try {
|
|
134
134
|
const data = await fetch(
|
|
135
135
|
`https://api.zerobounce.net/v2/validate?api_key=${process.env.ZEROBOUNCE_API_KEY}&email=${encodeURIComponent(email)}`,
|
|
136
|
-
{ response: 'json', timeout:
|
|
136
|
+
{ response: 'json', timeout: 60000 }
|
|
137
137
|
);
|
|
138
138
|
|
|
139
139
|
if (data.error) {
|