backend-manager 5.2.3 → 5.2.6
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 +52 -0
- package/CLAUDE.md +3 -3
- 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/consent.md +5 -10
- package/docs/sanitization.md +32 -24
- package/docs/schemas.md +1 -1
- package/docs/stripe-webhook-forwarding.md +2 -2
- package/docs/testing.md +8 -7
- 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/helpers/middleware.js +7 -4
- package/src/manager/helpers/utilities.js +31 -0
- package/src/manager/libraries/email/generators/newsletter.js +2 -2
- package/src/manager/libraries/email/providers/beehiiv.js +69 -27
- package/src/manager/libraries/email/providers/sendgrid.js +38 -12
- package/src/manager/libraries/email/validation.js +1 -1
- package/src/manager/libraries/infer-contact.js +1 -1
- 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/test/runner.js +7 -31
- package/src/test/test-accounts.js +8 -63
- 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/marketing/consent-lifecycle.js +255 -0
- package/test/routes/payments/dispute-alert.js +13 -13
- package/test/routes/payments/webhook.js +3 -3
- /package/src/manager/routes/general/email/templates/{download-app-link.js → general/download-app-link.js} +0 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Test Helper — direct provider lookups + removals via API
|
|
5
|
+
*
|
|
6
|
+
* Lets the live-test checklist verify SendGrid + Beehiiv state without
|
|
7
|
+
* clicking around in dashboards. Reuses the same provider helpers that
|
|
8
|
+
* BEM uses in production (findContact / removeContact).
|
|
9
|
+
*
|
|
10
|
+
* Usage (run from a consumer project's functions/ dir):
|
|
11
|
+
*
|
|
12
|
+
* node ../../../backend-manager/scripts/test-helper-providers.js find user@example.com
|
|
13
|
+
* node ../../../backend-manager/scripts/test-helper-providers.js purge user@example.com
|
|
14
|
+
*
|
|
15
|
+
* Or symlink: `ln -s ../../../backend-manager/scripts/test-helper-providers.js ./prov`
|
|
16
|
+
* then: `node ./prov find user@example.com`
|
|
17
|
+
*
|
|
18
|
+
* - find: prints whether the contact exists in SendGrid + Beehiiv (and the data shape).
|
|
19
|
+
* - purge: removes the contact from BOTH providers (idempotent; safe if absent).
|
|
20
|
+
*
|
|
21
|
+
* The script picks up:
|
|
22
|
+
* - SENDGRID_API_KEY, BEEHIIV_API_KEY from <cwd>/.env (functions/.env)
|
|
23
|
+
* - marketing.sendgrid.listId, marketing.beehiiv.publicationId from <cwd>/backend-manager-config.json
|
|
24
|
+
* - service-account from <cwd>/service-account.json
|
|
25
|
+
*
|
|
26
|
+
* Exit codes: 0 ok, 1 usage error, 2 provider error.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const path = require('path');
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
|
|
32
|
+
const cwd = process.cwd();
|
|
33
|
+
const envPath = path.join(cwd, '.env');
|
|
34
|
+
const configPath = path.join(cwd, 'backend-manager-config.json');
|
|
35
|
+
const serviceAccountPath = path.join(cwd, 'service-account.json');
|
|
36
|
+
|
|
37
|
+
// --- sanity checks ---
|
|
38
|
+
if (!fs.existsSync(envPath)) {
|
|
39
|
+
console.error(`✗ Missing ${envPath} — run from the consumer's functions/ dir`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
if (!fs.existsSync(configPath)) {
|
|
43
|
+
console.error(`✗ Missing ${configPath} — run from the consumer's functions/ dir`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
if (!fs.existsSync(serviceAccountPath)) {
|
|
47
|
+
console.error(`✗ Missing ${serviceAccountPath} — run from the consumer's functions/ dir`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// --- parse args ---
|
|
52
|
+
const [, , cmd, email] = process.argv;
|
|
53
|
+
const VALID_CMDS = ['find', 'purge'];
|
|
54
|
+
|
|
55
|
+
if (!cmd || !VALID_CMDS.includes(cmd) || !email) {
|
|
56
|
+
console.error('Usage:');
|
|
57
|
+
console.error(' node test-helper-providers.js find <email>');
|
|
58
|
+
console.error(' node test-helper-providers.js purge <email>');
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- bootstrap env (dotenv style, minimal) ---
|
|
63
|
+
require('dotenv').config({ path: envPath });
|
|
64
|
+
|
|
65
|
+
if (!process.env.SENDGRID_API_KEY) {
|
|
66
|
+
console.error('✗ SENDGRID_API_KEY not set in .env');
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
if (!process.env.BEEHIIV_API_KEY) {
|
|
70
|
+
console.error('✗ BEEHIIV_API_KEY not set in .env');
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- bootstrap Manager so providers can read Manager.config.marketing.* ---
|
|
75
|
+
// The providers require '../../../index.js' which is the Manager singleton.
|
|
76
|
+
// We need to load BEM from the consumer's node_modules (not the BEM repo's own src)
|
|
77
|
+
// so it picks up the consumer's config + service account.
|
|
78
|
+
let Manager;
|
|
79
|
+
try {
|
|
80
|
+
const BackendManager = require(path.join(cwd, 'node_modules', 'backend-manager'));
|
|
81
|
+
Manager = (new BackendManager()).init({}, { setupFunctionsLegacy: false, log: false });
|
|
82
|
+
} catch (e) {
|
|
83
|
+
console.error('✗ Failed to bootstrap Manager from consumer node_modules:', e.message);
|
|
84
|
+
process.exit(2);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// --- load providers via the BEM Manager.libraries surface (preferred) or direct path ---
|
|
88
|
+
const sendgridProviderPath = path.join(cwd, 'node_modules', 'backend-manager', 'src', 'manager', 'libraries', 'email', 'providers', 'sendgrid.js');
|
|
89
|
+
const beehiivProviderPath = path.join(cwd, 'node_modules', 'backend-manager', 'src', 'manager', 'libraries', 'email', 'providers', 'beehiiv.js');
|
|
90
|
+
|
|
91
|
+
const sendgrid = require(sendgridProviderPath);
|
|
92
|
+
const beehiiv = require(beehiivProviderPath);
|
|
93
|
+
|
|
94
|
+
// --- commands ---
|
|
95
|
+
async function find(email) {
|
|
96
|
+
console.log(`Looking up ${email} on both providers...`);
|
|
97
|
+
|
|
98
|
+
const [sgContact, bhContact] = await Promise.all([
|
|
99
|
+
sendgrid.findContact(email).catch(e => ({ _error: e.message })),
|
|
100
|
+
beehiiv.findContact(email).catch(e => ({ _error: e.message })),
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
console.log('\n── SendGrid ──');
|
|
104
|
+
if (sgContact?._error) {
|
|
105
|
+
console.log(` ✗ error: ${sgContact._error}`);
|
|
106
|
+
} else if (!sgContact) {
|
|
107
|
+
console.log(' ⊘ not found');
|
|
108
|
+
} else {
|
|
109
|
+
console.log(` ✓ found — id=${sgContact.id || '?'}, email=${sgContact.email || '?'}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
console.log('\n── Beehiiv ──');
|
|
113
|
+
if (bhContact?._error) {
|
|
114
|
+
console.log(` ✗ error: ${bhContact._error}`);
|
|
115
|
+
} else if (!bhContact) {
|
|
116
|
+
console.log(' ⊘ not found');
|
|
117
|
+
} else {
|
|
118
|
+
console.log(` ✓ found — id=${bhContact.id || '?'}, email=${bhContact.email || '?'}, status=${bhContact.status || '?'}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { sgContact, bhContact };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function purge(email) {
|
|
125
|
+
console.log(`Removing ${email} from both providers...`);
|
|
126
|
+
|
|
127
|
+
const [sgResult, bhResult] = await Promise.all([
|
|
128
|
+
sendgrid.removeContact(email).catch(e => ({ _error: e.message })),
|
|
129
|
+
beehiiv.removeContact(email).catch(e => ({ _error: e.message })),
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
console.log('\n── SendGrid ──');
|
|
133
|
+
if (sgResult?._error) {
|
|
134
|
+
console.log(` ✗ error: ${sgResult._error}`);
|
|
135
|
+
} else {
|
|
136
|
+
console.log(` ✓ ${JSON.stringify(sgResult)}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
console.log('\n── Beehiiv ──');
|
|
140
|
+
if (bhResult?._error) {
|
|
141
|
+
console.log(` ✗ error: ${bhResult._error}`);
|
|
142
|
+
} else {
|
|
143
|
+
console.log(` ✓ ${JSON.stringify(bhResult)}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { sgResult, bhResult };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- main ---
|
|
150
|
+
(async () => {
|
|
151
|
+
try {
|
|
152
|
+
if (cmd === 'find') {
|
|
153
|
+
await find(email);
|
|
154
|
+
} else if (cmd === 'purge') {
|
|
155
|
+
await purge(email);
|
|
156
|
+
}
|
|
157
|
+
process.exit(0);
|
|
158
|
+
} catch (e) {
|
|
159
|
+
console.error('✗ Unexpected error:', e);
|
|
160
|
+
process.exit(2);
|
|
161
|
+
}
|
|
162
|
+
})();
|
|
@@ -236,7 +236,7 @@ class BaseCommand {
|
|
|
236
236
|
this.log(chalk.gray(' (Stripe webhook forwarding is currently disabled - coming soon!)\n'));
|
|
237
237
|
return null;
|
|
238
238
|
|
|
239
|
-
// Load .env so STRIPE_SECRET_KEY and
|
|
239
|
+
// Load .env so STRIPE_SECRET_KEY and BACKEND_MANAGER_WEBHOOK_KEY are available
|
|
240
240
|
const envPath = path.join(functionsDir, '.env');
|
|
241
241
|
if (jetpack.exists(envPath)) {
|
|
242
242
|
require('dotenv').config({ path: envPath, quiet: true });
|
|
@@ -248,9 +248,9 @@ class BaseCommand {
|
|
|
248
248
|
return null;
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
-
// Check for Backend Manager key
|
|
252
|
-
if (!process.env.
|
|
253
|
-
this.log(chalk.gray(' (Stripe webhook forwarding disabled -
|
|
251
|
+
// Check for Backend Manager webhook key
|
|
252
|
+
if (!process.env.BACKEND_MANAGER_WEBHOOK_KEY) {
|
|
253
|
+
this.log(chalk.gray(' (Stripe webhook forwarding disabled - BACKEND_MANAGER_WEBHOOK_KEY not set in .env)\n'));
|
|
254
254
|
return null;
|
|
255
255
|
}
|
|
256
256
|
|
|
@@ -276,7 +276,7 @@ class BaseCommand {
|
|
|
276
276
|
}
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
-
const forwardUrl = `http://localhost:${hostingPort}/backend-manager/payments/webhook?processor=stripe&key=${process.env.
|
|
279
|
+
const forwardUrl = `http://localhost:${hostingPort}/backend-manager/payments/webhook?processor=stripe&key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`;
|
|
280
280
|
|
|
281
281
|
this.log(chalk.gray(` Stripe webhook forwarding -> localhost:${hostingPort}\n`));
|
|
282
282
|
|
|
@@ -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: {
|