@i-santos/firestack 1.0.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.
@@ -0,0 +1,1094 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
3
+ import { dirname, isAbsolute, normalize, relative, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { loadProjectConfig } from './config.mjs';
6
+ import { createDockerTask, defaultBootstrapCommand } from './docker-runner.mjs';
7
+ import { parseEnvFile, resolveFunctionsRuntimeEnv } from './functions-env.mjs';
8
+
9
+ const ANSI = {
10
+ reset: '\x1b[0m',
11
+ bold: '\x1b[1m',
12
+ dim: '\x1b[2m',
13
+ red: '\x1b[31m',
14
+ green: '\x1b[32m',
15
+ yellow: '\x1b[33m',
16
+ cyan: '\x1b[36m',
17
+ };
18
+
19
+ function supportsAnsiColor() {
20
+ if (process.env.NO_COLOR) return false;
21
+ if (process.env.FORCE_COLOR === '0') return false;
22
+ if (process.env.FORCE_COLOR) return true;
23
+ if (!process.stdout.isTTY) return false;
24
+ return process.env.TERM !== 'dumb';
25
+ }
26
+
27
+ const COLORS_ENABLED = supportsAnsiColor();
28
+
29
+ function paint(text, ...styles) {
30
+ if (!COLORS_ENABLED || styles.length === 0) return text;
31
+ return `${styles.join('')}${text}${ANSI.reset}`;
32
+ }
33
+
34
+ function printHelp() {
35
+ console.log(
36
+ 'Usage: firestack test [--ci|--unit|--integration|--e2e|--staging] [--docker] [--docker-rebuild] [--fail-fast] [--full] ' +
37
+ '[--profile <alias>] [--firebase-config <path>] ' +
38
+ '[--infra-logs <compact|verbose|quiet>] [--infra-log-file <path>] [--suite-log-file <path>] [--log-append] [--no-log-routing] ' +
39
+ '[--target <dir>] [--config <path>]'
40
+ );
41
+ }
42
+
43
+ function runShell(cwd, script, label, env = process.env) {
44
+ const result = spawnSync('bash', ['-lc', script], {
45
+ cwd,
46
+ stdio: 'inherit',
47
+ env,
48
+ });
49
+ if (result.error) {
50
+ throw new Error(`${label}: ${result.error.message}`);
51
+ }
52
+ return result.status ?? 1;
53
+ }
54
+
55
+ function resolveFirebaseProjectFromRc(cwd, { preferredAlias = null, strictAlias = false } = {}) {
56
+ const rcPath = resolve(cwd, '.firebaserc');
57
+ if (!existsSync(rcPath)) return null;
58
+
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(readFileSync(rcPath, 'utf8'));
62
+ } catch {
63
+ throw new Error(`invalid JSON in ${rcPath}`);
64
+ }
65
+
66
+ const projects = parsed?.projects;
67
+ if (!projects || typeof projects !== 'object') return null;
68
+
69
+ const envAlias = typeof process.env.FIREBASE_ALIAS === 'string' && process.env.FIREBASE_ALIAS.trim()
70
+ ? process.env.FIREBASE_ALIAS.trim()
71
+ : null;
72
+ const selectedAlias = preferredAlias || envAlias || 'default';
73
+
74
+ let alias = selectedAlias;
75
+ let projectId = projects[alias];
76
+
77
+ if (typeof projectId !== 'string' || !projectId.trim()) {
78
+ if (strictAlias) {
79
+ throw new Error(`missing Firebase project alias "${alias}" in ${rcPath}`);
80
+ }
81
+ const firstAlias = Object.keys(projects).find((key) => typeof projects[key] === 'string' && projects[key].trim());
82
+ if (!firstAlias) return null;
83
+ alias = firstAlias;
84
+ projectId = projects[firstAlias];
85
+ }
86
+
87
+ return { alias, projectId: projectId.trim() };
88
+ }
89
+
90
+ function profileVariants(alias) {
91
+ if (alias === 'default') return ['default', 'development'];
92
+ return [alias];
93
+ }
94
+
95
+ function loadProfileEnv(cwd, profileAlias) {
96
+ const variants = profileVariants(profileAlias);
97
+ const candidates = [
98
+ '.env',
99
+ '.env.test',
100
+ ...variants.flatMap((variant) => [`.env.${variant}`, `.env.test.${variant}`]),
101
+ ];
102
+ const merged = {};
103
+ for (const fileName of candidates) {
104
+ const fullPath = resolve(cwd, fileName);
105
+ if (!existsSync(fullPath)) continue;
106
+ Object.assign(merged, parseEnvFile(readFileSync(fullPath, 'utf8')));
107
+ }
108
+ return merged;
109
+ }
110
+
111
+ function resolveProfileAlias(args) {
112
+ const explicit = typeof args.profile === 'string' ? args.profile.trim() : '';
113
+ if (explicit) return explicit;
114
+ if (args.staging) return 'staging';
115
+ const envAlias = typeof process.env.FIREBASE_ALIAS === 'string' ? process.env.FIREBASE_ALIAS.trim() : '';
116
+ return envAlias || 'default';
117
+ }
118
+
119
+ function resolveFirebaseConfigPath(cwd, { explicitPath = null, profileAlias = 'default' } = {}) {
120
+ if (explicitPath) {
121
+ const resolvedPath = isAbsolute(explicitPath) ? explicitPath : resolve(cwd, explicitPath);
122
+ if (!existsSync(resolvedPath)) {
123
+ throw new Error(`firebase config file not found: ${resolvedPath}`);
124
+ }
125
+ return resolvedPath;
126
+ }
127
+
128
+ const candidates = [
129
+ ...profileVariants(profileAlias).map((variant) => resolve(cwd, `firebase.${variant}.json`)),
130
+ resolve(cwd, 'firebase.json'),
131
+ ];
132
+ return candidates.find((candidate) => existsSync(candidate)) ?? null;
133
+ }
134
+
135
+ export function resolveTestEnv(cwd, {
136
+ profileAlias,
137
+ firebaseConfigPath = null,
138
+ ignoreAmbientGcloudProject = false,
139
+ } = {}) {
140
+ const profileEnv = loadProfileEnv(cwd, profileAlias);
141
+ const profileDefinesProject = Object.prototype.hasOwnProperty.call(profileEnv, 'GCLOUD_PROJECT')
142
+ && typeof profileEnv.GCLOUD_PROJECT === 'string'
143
+ && profileEnv.GCLOUD_PROJECT.trim();
144
+ const baseEnv = {
145
+ ...(ignoreAmbientGcloudProject && !profileDefinesProject
146
+ ? (() => {
147
+ const cloned = { ...process.env };
148
+ delete cloned.GCLOUD_PROJECT;
149
+ return cloned;
150
+ })()
151
+ : process.env),
152
+ ...profileEnv,
153
+ };
154
+ let source = null;
155
+ let env = { ...baseEnv };
156
+
157
+ if (!(typeof env.GCLOUD_PROJECT === 'string' && env.GCLOUD_PROJECT.trim())) {
158
+ const resolved = resolveFirebaseProjectFromRc(cwd, {
159
+ preferredAlias: profileAlias,
160
+ strictAlias: profileAlias !== 'default' || Boolean(process.env.FIREBASE_ALIAS?.trim()),
161
+ });
162
+ if (resolved) {
163
+ source = resolved;
164
+ env = {
165
+ ...env,
166
+ GCLOUD_PROJECT: resolved.projectId,
167
+ FIREBASE_PROJECT_ALIAS: resolved.alias,
168
+ };
169
+ }
170
+ }
171
+
172
+ const functionsRuntime = resolveFunctionsRuntimeEnv(cwd, {
173
+ projectId: env.GCLOUD_PROJECT?.trim() || null,
174
+ firebaseConfigPath,
175
+ includeLocal: profileAlias !== 'staging',
176
+ });
177
+
178
+ return {
179
+ env: {
180
+ ...env,
181
+ ...functionsRuntime.env,
182
+ },
183
+ source,
184
+ functionsRuntime,
185
+ };
186
+ }
187
+
188
+ function isCiKey(key) {
189
+ return key === 'ci' || key === 'ciFailFast' || key === 'ciFull' || key === 'ciFailFastFull';
190
+ }
191
+
192
+ function isE2eKey(key) {
193
+ return key === 'e2eSmoke' || key === 'e2eFull';
194
+ }
195
+
196
+ function isStagingKey(key) {
197
+ return key === 'stagingSmoke' || key === 'stagingFull';
198
+ }
199
+
200
+ export function mapCommandKey(args) {
201
+ const explicitCount = [args.ci, args.unit, args.integration, args.e2e, args.staging].filter(Boolean).length;
202
+ const suffix = args.full ? 'Full' : 'Smoke';
203
+
204
+ if (explicitCount > 1) {
205
+ throw new Error('invalid test scope: choose only one of --ci, --unit, --integration, --e2e, --staging');
206
+ }
207
+ if (explicitCount === 0 || args.ci) {
208
+ if (args.failFast) return args.full ? 'ciFailFastFull' : 'ciFailFast';
209
+ return args.full ? 'ciFull' : 'ci';
210
+ }
211
+ if (args.unit) return 'unit';
212
+ if (args.integration) return 'integration';
213
+ if (args.e2e) return `e2e${suffix}`;
214
+ if (args.staging) return `staging${suffix}`;
215
+ if (args.failFast) return args.full ? 'ciFailFastFull' : 'ciFailFast';
216
+ return args.full ? 'ciFull' : 'ci';
217
+ }
218
+
219
+ export function promoteCiCommandToFullE2E(command) {
220
+ if (typeof command !== 'string' || command.length === 0) return command;
221
+ return command.replace(/(\binternal\s+run-e2e)\s+smoke\b/g, '$1 full');
222
+ }
223
+
224
+ function isOverrideAllowed(env = process.env) {
225
+ return env.ALLOW_NON_STAGING_E2E === 'true';
226
+ }
227
+
228
+ function escapeShell(value) {
229
+ return `'${String(value).replace(/'/g, `'\"'\"'`)}'`;
230
+ }
231
+
232
+ function assertNoExternalBaseUrlForCi(env, logPrefix) {
233
+ const raw = env.E2E_BASE_URL?.trim();
234
+ if (!raw) return;
235
+ if (isOverrideAllowed(env)) return;
236
+ throw new Error(
237
+ `${logPrefix} refusing E2E_BASE_URL in CI docker gate. Allowed targets are local emulators only. ` +
238
+ 'Set ALLOW_NON_STAGING_E2E=true to override explicitly.'
239
+ );
240
+ }
241
+
242
+ function normalizeHost(rawUrl) {
243
+ const url = new URL(rawUrl);
244
+ return url.hostname.toLowerCase();
245
+ }
246
+
247
+ function validateExternalBaseUrl(baseUrl, env, logPrefix) {
248
+ if (!baseUrl) return;
249
+ if (isOverrideAllowed(env)) return;
250
+
251
+ let host;
252
+ try {
253
+ host = normalizeHost(baseUrl);
254
+ } catch {
255
+ throw new Error(`${logPrefix} invalid E2E_BASE_URL: ${baseUrl}`);
256
+ }
257
+
258
+ const allowedHosts = new Set(['localhost', '127.0.0.1', 'staging.presentgoal.com']);
259
+ if (!allowedHosts.has(host)) {
260
+ throw new Error(
261
+ `${logPrefix} refusing E2E_BASE_URL host "${host}". Allowed: localhost, 127.0.0.1, staging.presentgoal.com. ` +
262
+ 'Set ALLOW_NON_STAGING_E2E=true to override explicitly.'
263
+ );
264
+ }
265
+ }
266
+
267
+ function validateStagingBaseUrl(baseUrl, env, logPrefix) {
268
+ if (!baseUrl) return;
269
+ if (isOverrideAllowed(env)) return;
270
+
271
+ let host;
272
+ try {
273
+ host = normalizeHost(baseUrl);
274
+ } catch {
275
+ throw new Error(`${logPrefix} invalid E2E_BASE_URL: ${baseUrl}`);
276
+ }
277
+
278
+ if (host !== 'staging.presentgoal.com') {
279
+ throw new Error(
280
+ `${logPrefix} refusing E2E_BASE_URL host "${host}" for staging runner. Allowed only: staging.presentgoal.com. ` +
281
+ 'Set ALLOW_NON_STAGING_E2E=true to override explicitly.'
282
+ );
283
+ }
284
+ }
285
+
286
+ function requireProject(expectedProjectId, currentProjectId, logPrefix) {
287
+ if (currentProjectId !== expectedProjectId) {
288
+ throw new Error(`${logPrefix} refusing to run with GCLOUD_PROJECT=${currentProjectId}. Expected ${expectedProjectId}.`);
289
+ }
290
+ return currentProjectId;
291
+ }
292
+
293
+ function buildDockerLogPrefix(key) {
294
+ if (isCiKey(key)) return '[test:ci:docker]';
295
+ if (key === 'integration') return '[test:integration:docker]';
296
+ if (key === 'unit') return '[test:unit:docker]';
297
+ if (isStagingKey(key)) return '[test:e2e:staging:docker]';
298
+ if (isE2eKey(key)) return '[test:e2e:docker]';
299
+ return '[test:docker]';
300
+ }
301
+
302
+ function parsePlaywrightConfigPathFromCommand(command) {
303
+ const fromEquals = command.match(/--config=([^\s"'`]+)/);
304
+ if (fromEquals) return fromEquals[1];
305
+ const fromSpace = command.match(/--config\s+([^\s"'`]+)/);
306
+ if (fromSpace) return fromSpace[1];
307
+ return null;
308
+ }
309
+
310
+ function resolvePlaywrightConfigPath(cwd, command) {
311
+ const explicit = process.env.PLAYWRIGHT_CONFIG_PATH?.trim() || parsePlaywrightConfigPathFromCommand(command);
312
+ if (explicit) {
313
+ const candidate = isAbsolute(explicit) ? explicit : resolve(cwd, explicit);
314
+ if (existsSync(candidate)) return candidate;
315
+ }
316
+
317
+ const defaultNames = [
318
+ 'playwright.config.ts',
319
+ 'playwright.config.mts',
320
+ 'playwright.config.cts',
321
+ 'playwright.config.js',
322
+ 'playwright.config.mjs',
323
+ 'playwright.config.cjs',
324
+ ];
325
+ for (const fileName of defaultNames) {
326
+ const candidate = resolve(cwd, fileName);
327
+ if (existsSync(candidate)) return candidate;
328
+ }
329
+ return null;
330
+ }
331
+
332
+ function rewriteInternalFirestackInvocations(command, internalBinPath) {
333
+ if (typeof command !== 'string' || command.length === 0) return command;
334
+ const replacement = `node ${escapeShell(internalBinPath)} internal`;
335
+ return command.replace(
336
+ /\b(?:npx\s+@i-santos\/firestack|npx\s+firestack|firestack)\s+internal\b/g,
337
+ replacement
338
+ );
339
+ }
340
+
341
+ function rewriteFirebaseCliInvocations(command) {
342
+ if (typeof command !== 'string' || command.length === 0) return command;
343
+ return command.replace(/\bnpx\s+firebase-tools\b/g, 'firebase');
344
+ }
345
+
346
+ function applyFirebaseConfigToCommand(command, firebaseConfigPath) {
347
+ if (typeof command !== 'string' || command.length === 0 || !firebaseConfigPath) return command;
348
+ const configArg = `--config ${escapeShell(firebaseConfigPath)}`;
349
+ return command.replace(/\bfirebase\s+emulators:exec\b/g, `firebase ${configArg} emulators:exec`);
350
+ }
351
+
352
+ function commandIncludesFirebaseEmulatorsExec(command) {
353
+ if (typeof command !== 'string' || command.length === 0) return false;
354
+ return /\bfirebase\b[\s\S]*\bemulators:exec\b/.test(command);
355
+ }
356
+
357
+ function buildLogRoutedCommand(command, { routerScriptPath, mode, infraLogFile, suiteLogFile, appendLogs }) {
358
+ const ttyWidth = Number.isFinite(Number(process.stdout.columns)) && Number(process.stdout.columns) > 0
359
+ ? String(process.stdout.columns)
360
+ : '120';
361
+ const forcedStyleEnv = [
362
+ 'FORCE_COLOR=1',
363
+ 'CLICOLOR_FORCE=1',
364
+ 'NPM_CONFIG_COLOR=always',
365
+ `PLAYWRIGHT_FORCE_TTY=${ttyWidth}`,
366
+ 'TERM=xterm-256color',
367
+ ].join(' ');
368
+ const routerCommand = [
369
+ 'node',
370
+ escapeShell(routerScriptPath),
371
+ '--mode',
372
+ escapeShell(mode),
373
+ '--infra-log',
374
+ escapeShell(infraLogFile),
375
+ '--suite-log',
376
+ escapeShell(suiteLogFile),
377
+ ...(appendLogs ? ['--append'] : ['--reset']),
378
+ ].join(' ');
379
+ return `set -o pipefail; env ${forcedStyleEnv} bash -lc ${escapeShell(command)} 2>&1 | ${routerCommand}`;
380
+ }
381
+
382
+ function normalizeRelativeWritablePath(cwd, rawPath) {
383
+ if (typeof rawPath !== 'string') return null;
384
+ const trimmed = rawPath.trim();
385
+ if (!trimmed) return null;
386
+ if (trimmed.includes('://')) return null;
387
+ if (trimmed.startsWith('~')) return null;
388
+
389
+ const absoluteCandidate = isAbsolute(trimmed) ? trimmed : resolve(cwd, trimmed);
390
+ const rel = relative(cwd, absoluteCandidate);
391
+ if (!rel || rel === '.') return null;
392
+ if (rel.startsWith('..') || isAbsolute(rel)) return null;
393
+ return normalize(rel).replaceAll('\\', '/').replace(/\/+$/, '');
394
+ }
395
+
396
+ function detectWritablePathsFromPlaywrightConfig(cwd, command, logPrefix) {
397
+ const configPath = resolvePlaywrightConfigPath(cwd, command);
398
+ if (!configPath) return [];
399
+
400
+ let source = '';
401
+ try {
402
+ source = readFileSync(configPath, 'utf8');
403
+ } catch {
404
+ return [];
405
+ }
406
+
407
+ const paths = new Set();
408
+ const extractors = [
409
+ /outputFolder\s*:\s*['"`]([^'"`]+)['"`]/g,
410
+ /outputFile\s*:\s*['"`]([^'"`]+)['"`]/g,
411
+ /outputDir\s*:\s*['"`]([^'"`]+)['"`]/g,
412
+ ];
413
+
414
+ for (const regex of extractors) {
415
+ let match = regex.exec(source);
416
+ while (match) {
417
+ const raw = match[1];
418
+ const candidate = regex.source.includes('outputFile') ? dirname(raw) : raw;
419
+ const normalizedPath = normalizeRelativeWritablePath(cwd, candidate);
420
+ if (normalizedPath) {
421
+ paths.add(normalizedPath);
422
+ } else if (candidate) {
423
+ console.warn(`${logPrefix} ignored non-local playwright output path: ${candidate}`);
424
+ }
425
+ match = regex.exec(source);
426
+ }
427
+ }
428
+
429
+ return Array.from(paths);
430
+ }
431
+
432
+ function resolveDockerWritablePaths(cwd, key, command, dockerConfig, logPrefix) {
433
+ const configured = Array.isArray(dockerConfig.writablePaths) && dockerConfig.writablePaths.length > 0
434
+ ? dockerConfig.writablePaths
435
+ : ['out'];
436
+ const merged = new Set(configured.map((entry) => String(entry).trim()).filter(Boolean));
437
+ const includesE2E = isCiKey(key) || isE2eKey(key) || isStagingKey(key);
438
+
439
+ if (!includesE2E) {
440
+ return Array.from(merged);
441
+ }
442
+
443
+ const fromPlaywright = detectWritablePathsFromPlaywrightConfig(cwd, command, logPrefix);
444
+ for (const path of fromPlaywright) {
445
+ merged.add(path);
446
+ }
447
+
448
+ if (fromPlaywright.length === 0) {
449
+ merged.add('out');
450
+ }
451
+
452
+ return Array.from(merged);
453
+ }
454
+
455
+ function decodeXmlEntities(text) {
456
+ return text
457
+ .replaceAll('&lt;', '<')
458
+ .replaceAll('&gt;', '>')
459
+ .replaceAll('&quot;', '"')
460
+ .replaceAll('&apos;', "'")
461
+ .replaceAll('&amp;', '&');
462
+ }
463
+
464
+ function parseAttributes(tagSource) {
465
+ const attrs = {};
466
+ const attrRe = /([a-zA-Z_:][\w:.-]*)="([^"]*)"/g;
467
+ let match = attrRe.exec(tagSource);
468
+ while (match) {
469
+ attrs[match[1]] = decodeXmlEntities(match[2]);
470
+ match = attrRe.exec(tagSource);
471
+ }
472
+ return attrs;
473
+ }
474
+
475
+ function extractFailureSummary(rawFailureText, failureMessage) {
476
+ if (typeof rawFailureText === 'string' && rawFailureText.trim()) {
477
+ const errorMatch = rawFailureText.match(/Error:\s*(.+)/);
478
+ if (errorMatch?.[1]) return errorMatch[1].trim();
479
+ const firstMeaningfulLine = rawFailureText
480
+ .split('\n')
481
+ .map((line) => line.trim())
482
+ .find((line) => line && !line.startsWith('at ') && !line.startsWith('attachment #') && !line.startsWith('Usage:'));
483
+ if (firstMeaningfulLine) return firstMeaningfulLine;
484
+ }
485
+ if (typeof failureMessage === 'string' && failureMessage.trim()) {
486
+ return failureMessage.trim();
487
+ }
488
+ return 'failed';
489
+ }
490
+
491
+ function extractFailureDetails(rawFailureText) {
492
+ if (typeof rawFailureText !== 'string' || !rawFailureText.trim()) return {};
493
+ const expectedPattern = rawFailureText.match(/Expected pattern:\s*(.+)/)?.[1]?.trim();
494
+ const receivedString = rawFailureText.match(/Received string:\s*"([^"]+)"/)?.[1]?.trim();
495
+ const timeoutMs = rawFailureText.match(/Timeout:\s*(\d+)ms/)?.[1]?.trim();
496
+ return {
497
+ expectedPattern: expectedPattern || null,
498
+ receivedString: receivedString || null,
499
+ timeoutMs: timeoutMs || null,
500
+ };
501
+ }
502
+
503
+ function extractArtifactPaths(rawFailureText) {
504
+ if (typeof rawFailureText !== 'string' || !rawFailureText.trim()) return [];
505
+ const pathRe = /(?:^|\s)(out\/tests\/[^\s]+(?:\.(?:png|webm|zip|md)|\/?))/gm;
506
+ const artifacts = [];
507
+ let match = pathRe.exec(rawFailureText);
508
+ while (match) {
509
+ const value = match[1].trim();
510
+ if (value && !artifacts.includes(value)) artifacts.push(value);
511
+ match = pathRe.exec(rawFailureText);
512
+ }
513
+ return artifacts;
514
+ }
515
+
516
+ function parseTestcases(xml) {
517
+ const cases = [];
518
+ const caseRe = /<testcase\b([\s\S]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g;
519
+ let match = caseRe.exec(xml);
520
+ while (match) {
521
+ const attrs = parseAttributes(match[1] ?? '');
522
+ const body = match[2] ?? '';
523
+ const failureTag = body.match(/<failure\b([\s\S]*?)>([\s\S]*?)<\/failure>/);
524
+ const skippedTag = body.match(/<skipped\b[\s\S]*?>/);
525
+ const durationMs = Number.isFinite(Number(attrs.time)) ? Math.round(Number(attrs.time) * 1000) : 0;
526
+ let status = 'pass';
527
+ if (skippedTag) status = 'skipped';
528
+ if (failureTag) status = 'fail';
529
+ let failureMessage = null;
530
+ let failureLocation = null;
531
+ let failureSummary = null;
532
+ let artifactPaths = [];
533
+ let failureDetails = {};
534
+ if (failureTag) {
535
+ const failureAttrs = parseAttributes(failureTag[1] ?? '');
536
+ failureMessage = decodeXmlEntities(failureAttrs.message ?? 'failed');
537
+ const rawFailureText = decodeXmlEntities(
538
+ (failureTag[2] ?? '')
539
+ .replace('<![CDATA[', '')
540
+ .replace(']]>', '')
541
+ .trim()
542
+ );
543
+ failureSummary = extractFailureSummary(rawFailureText, failureMessage);
544
+ failureDetails = extractFailureDetails(rawFailureText);
545
+ artifactPaths = extractArtifactPaths(rawFailureText);
546
+ const locationMatch = rawFailureText.match(/([A-Za-z0-9_./\\-]+\.(?:[cm]?[jt]sx?|mjs|cjs)):(\d+):(\d+)/);
547
+ if (locationMatch) {
548
+ failureLocation = `${locationMatch[1]}:${locationMatch[2]}:${locationMatch[3]}`;
549
+ } else if (attrs.classname) {
550
+ failureLocation = attrs.classname;
551
+ }
552
+ }
553
+ cases.push({
554
+ name: attrs.name || '(unnamed testcase)',
555
+ durationMs,
556
+ status,
557
+ failureMessage,
558
+ failureLocation,
559
+ failureSummary,
560
+ failureDetails,
561
+ artifactPaths,
562
+ });
563
+ match = caseRe.exec(xml);
564
+ }
565
+ return cases;
566
+ }
567
+
568
+ function readJUnit(path, suiteName) {
569
+ if (!existsSync(path)) return null;
570
+ try {
571
+ const xml = readFileSync(path, 'utf8');
572
+ const cases = parseTestcases(xml);
573
+ const tests = cases.length;
574
+ const failures = cases.filter((c) => c.status === 'fail').length;
575
+ const skipped = cases.filter((c) => c.status === 'skipped').length;
576
+ const passed = Math.max(tests - failures - skipped, 0);
577
+ const durationMs = cases.reduce((sum, c) => sum + c.durationMs, 0);
578
+ return { suiteName, tests, failures, skipped, passed, durationMs, cases };
579
+ } catch {
580
+ return null;
581
+ }
582
+ }
583
+
584
+ function buildSummaryTable(suites, total) {
585
+ const rows = suites.map((suite) => ({
586
+ suite: suite.suiteName,
587
+ tests: suite.tests,
588
+ pass: suite.passed,
589
+ fail: suite.failures,
590
+ skip: suite.skipped,
591
+ time: (suite.durationMs / 1000).toFixed(2),
592
+ }));
593
+
594
+ const totalRow = {
595
+ suite: 'total',
596
+ tests: total.tests,
597
+ pass: total.passed,
598
+ fail: total.failures,
599
+ skip: total.skipped,
600
+ time: (total.durationMs / 1000).toFixed(2),
601
+ };
602
+
603
+ const header = {
604
+ suite: 'suite',
605
+ tests: 'tests',
606
+ pass: 'pass',
607
+ fail: 'fail',
608
+ skip: 'skip',
609
+ time: 'time(s)',
610
+ };
611
+
612
+ const widths = {
613
+ suite: Math.max(header.suite.length, ...rows.map((row) => row.suite.length), totalRow.suite.length),
614
+ tests: Math.max(header.tests.length, ...rows.map((row) => String(row.tests).length), String(totalRow.tests).length),
615
+ pass: Math.max(header.pass.length, ...rows.map((row) => String(row.pass).length), String(totalRow.pass).length),
616
+ fail: Math.max(header.fail.length, ...rows.map((row) => String(row.fail).length), String(totalRow.fail).length),
617
+ skip: Math.max(header.skip.length, ...rows.map((row) => String(row.skip).length), String(totalRow.skip).length),
618
+ time: Math.max(header.time.length, ...rows.map((row) => row.time.length), totalRow.time.length),
619
+ };
620
+
621
+ const colorSuite = (value, row, isTotal) => {
622
+ if (isTotal) return paint(value, ANSI.bold);
623
+ if (row.fail > 0) return paint(value, ANSI.red);
624
+ if (row.skip > 0) return paint(value, ANSI.yellow);
625
+ return paint(value, ANSI.green);
626
+ };
627
+
628
+ const colorPass = (value, isHeader) => (isHeader ? paint(value, ANSI.bold, ANSI.green) : paint(value, ANSI.green));
629
+ const colorFail = (value, count, isHeader) => {
630
+ if (isHeader) return paint(value, ANSI.bold, ANSI.red);
631
+ return count > 0 ? paint(value, ANSI.red) : paint(value, ANSI.dim);
632
+ };
633
+ const colorSkip = (value, count, isHeader) => {
634
+ if (isHeader) return paint(value, ANSI.bold, ANSI.yellow);
635
+ return count > 0 ? paint(value, ANSI.yellow) : paint(value, ANSI.dim);
636
+ };
637
+
638
+ const formatDataRow = (row, { isTotal = false } = {}) => {
639
+ const suiteCell = row.suite.padEnd(widths.suite, ' ');
640
+ const testsCell = String(row.tests).padStart(widths.tests, ' ');
641
+ const passCell = String(row.pass).padStart(widths.pass, ' ');
642
+ const failCell = String(row.fail).padStart(widths.fail, ' ');
643
+ const skipCell = String(row.skip).padStart(widths.skip, ' ');
644
+ const timeCell = row.time.padStart(widths.time, ' ');
645
+
646
+ return (
647
+ ` ${colorSuite(suiteCell, row, isTotal)} | ` +
648
+ `${isTotal ? paint(testsCell, ANSI.bold) : testsCell} | ` +
649
+ `${isTotal ? paint(passCell, ANSI.bold, ANSI.green) : colorPass(passCell, false)} | ` +
650
+ `${isTotal ? colorFail(paint(failCell, ANSI.bold), row.fail, false) : colorFail(failCell, row.fail, false)} | ` +
651
+ `${isTotal ? colorSkip(paint(skipCell, ANSI.bold), row.skip, false) : colorSkip(skipCell, row.skip, false)} | ` +
652
+ `${isTotal ? paint(timeCell, ANSI.bold) : timeCell}`
653
+ );
654
+ };
655
+
656
+ const formatHeaderRow = () => {
657
+ const suiteCell = header.suite.padEnd(widths.suite, ' ');
658
+ const testsCell = header.tests.padStart(widths.tests, ' ');
659
+ const passCell = header.pass.padStart(widths.pass, ' ');
660
+ const failCell = header.fail.padStart(widths.fail, ' ');
661
+ const skipCell = header.skip.padStart(widths.skip, ' ');
662
+ const timeCell = header.time.padStart(widths.time, ' ');
663
+ return (
664
+ ` ${paint(suiteCell, ANSI.bold, ANSI.cyan)} | ` +
665
+ `${paint(testsCell, ANSI.bold, ANSI.cyan)} | ` +
666
+ `${colorPass(passCell, true)} | ` +
667
+ `${colorFail(failCell, 0, true)} | ` +
668
+ `${colorSkip(skipCell, 0, true)} | ` +
669
+ `${paint(timeCell, ANSI.bold, ANSI.cyan)}`
670
+ );
671
+ };
672
+
673
+ const separator = (
674
+ ` ${'-'.repeat(widths.suite)}-+-` +
675
+ `${'-'.repeat(widths.tests)}-+-` +
676
+ `${'-'.repeat(widths.pass)}-+-` +
677
+ `${'-'.repeat(widths.fail)}-+-` +
678
+ `${'-'.repeat(widths.skip)}-+-` +
679
+ `${'-'.repeat(widths.time)}`
680
+ );
681
+
682
+ return {
683
+ header: formatHeaderRow(),
684
+ separator: paint(separator, ANSI.dim),
685
+ rows: rows.map((row) => formatDataRow(row)),
686
+ total: formatDataRow(totalRow, { isTotal: true }),
687
+ };
688
+ }
689
+
690
+ function printTestSummary(cwd, key) {
691
+ const suiteMap = {
692
+ unit: readJUnit(resolve(cwd, 'out/tests/unit/junit.xml'), 'unit'),
693
+ integration: readJUnit(resolve(cwd, 'out/tests/integration/junit.xml'), 'integration'),
694
+ e2e: readJUnit(resolve(cwd, 'out/tests/e2e/junit.xml'), 'e2e'),
695
+ 'e2e-staging': readJUnit(resolve(cwd, 'out/tests/e2e/staging/junit.xml'), 'e2e-staging'),
696
+ };
697
+
698
+ let expectedSuiteKeys = ['unit', 'integration', 'e2e', 'e2e-staging'];
699
+ if (key === 'unit') expectedSuiteKeys = ['unit'];
700
+ if (key === 'integration') expectedSuiteKeys = ['integration'];
701
+ if (isE2eKey(key)) expectedSuiteKeys = ['e2e'];
702
+ if (isStagingKey(key)) expectedSuiteKeys = ['e2e-staging'];
703
+ if (isCiKey(key)) expectedSuiteKeys = ['unit', 'integration', 'e2e'];
704
+
705
+ const suites = expectedSuiteKeys
706
+ .map((suiteKey) => suiteMap[suiteKey])
707
+ .filter(Boolean);
708
+
709
+ if (suites.length === 0) return;
710
+
711
+ const total = suites.reduce((acc, suite) => ({
712
+ tests: acc.tests + suite.tests,
713
+ passed: acc.passed + suite.passed,
714
+ failures: acc.failures + suite.failures,
715
+ skipped: acc.skipped + suite.skipped,
716
+ durationMs: acc.durationMs + suite.durationMs,
717
+ }), { tests: 0, passed: 0, failures: 0, skipped: 0, durationMs: 0 });
718
+
719
+ console.log('\n=== Firestack Test Report ===');
720
+ console.log(` Command: ${key}`);
721
+ const table = buildSummaryTable(suites, total);
722
+ console.log(table.separator);
723
+ console.log(table.header);
724
+ console.log(table.separator);
725
+ table.rows.forEach((row) => console.log(row));
726
+ console.log(table.separator);
727
+ console.log(table.total);
728
+
729
+ const failedCases = suites.flatMap((suite) => suite.cases
730
+ .filter((testcase) => testcase.status === 'fail')
731
+ .slice(0, 5)
732
+ .map((testcase) => ({ suiteName: suite.suiteName, testcase })));
733
+ if (failedCases.length > 0) {
734
+ console.log(`\n ${paint('Failures:', ANSI.bold, ANSI.red)}`);
735
+ failedCases.forEach(({ suiteName, testcase }) => {
736
+ const suiteLabel = paint(`[${suiteName}]`, ANSI.bold, ANSI.red);
737
+ console.log(` - ${suiteLabel} ${paint(testcase.name, ANSI.bold)}`);
738
+ if (testcase.failureLocation) {
739
+ console.log(` ${paint('location:', ANSI.dim)} ${paint(testcase.failureLocation, ANSI.cyan)}`);
740
+ }
741
+ if (testcase.failureSummary) {
742
+ console.log(` ${paint('error:', ANSI.dim)} ${paint(testcase.failureSummary, ANSI.red)}`);
743
+ } else if (testcase.failureMessage) {
744
+ console.log(` ${paint('error:', ANSI.dim)} ${paint(testcase.failureMessage, ANSI.red)}`);
745
+ }
746
+ if (testcase.failureDetails?.expectedPattern) {
747
+ console.log(` ${paint('expected:', ANSI.dim)} ${paint(testcase.failureDetails.expectedPattern, ANSI.green)}`);
748
+ }
749
+ if (testcase.failureDetails?.receivedString) {
750
+ console.log(` ${paint('received:', ANSI.dim)} ${paint(`"${testcase.failureDetails.receivedString}"`, ANSI.red)}`);
751
+ }
752
+ if (testcase.failureDetails?.timeoutMs) {
753
+ console.log(` ${paint('timeout:', ANSI.dim)} ${paint(`${testcase.failureDetails.timeoutMs}ms`, ANSI.yellow)}`);
754
+ }
755
+ if (Array.isArray(testcase.artifactPaths) && testcase.artifactPaths.length > 0) {
756
+ const firstArtifacts = testcase.artifactPaths.slice(0, 4);
757
+ console.log(` ${paint('artifacts:', ANSI.dim)} ${firstArtifacts.join(', ')}`);
758
+ }
759
+ });
760
+ }
761
+ }
762
+
763
+ function expectedJUnitPaths(cwd, key) {
764
+ const mapping = {
765
+ unit: [resolve(cwd, 'out/tests/unit/junit.xml')],
766
+ integration: [resolve(cwd, 'out/tests/integration/junit.xml')],
767
+ e2eSmoke: [resolve(cwd, 'out/tests/e2e/junit.xml')],
768
+ e2eFull: [resolve(cwd, 'out/tests/e2e/junit.xml')],
769
+ stagingSmoke: [resolve(cwd, 'out/tests/e2e/staging/junit.xml')],
770
+ stagingFull: [resolve(cwd, 'out/tests/e2e/staging/junit.xml')],
771
+ ci: [
772
+ resolve(cwd, 'out/tests/unit/junit.xml'),
773
+ resolve(cwd, 'out/tests/integration/junit.xml'),
774
+ resolve(cwd, 'out/tests/e2e/junit.xml'),
775
+ ],
776
+ ciFailFast: [
777
+ resolve(cwd, 'out/tests/unit/junit.xml'),
778
+ resolve(cwd, 'out/tests/integration/junit.xml'),
779
+ resolve(cwd, 'out/tests/e2e/junit.xml'),
780
+ ],
781
+ ciFull: [
782
+ resolve(cwd, 'out/tests/unit/junit.xml'),
783
+ resolve(cwd, 'out/tests/integration/junit.xml'),
784
+ resolve(cwd, 'out/tests/e2e/junit.xml'),
785
+ ],
786
+ ciFailFastFull: [
787
+ resolve(cwd, 'out/tests/unit/junit.xml'),
788
+ resolve(cwd, 'out/tests/integration/junit.xml'),
789
+ resolve(cwd, 'out/tests/e2e/junit.xml'),
790
+ ],
791
+ };
792
+ return mapping[key] ?? [];
793
+ }
794
+
795
+ function clearExpectedJUnitReports(cwd, key) {
796
+ for (const reportPath of expectedJUnitPaths(cwd, key)) {
797
+ try {
798
+ rmSync(reportPath, { force: true });
799
+ } catch {
800
+ // ignore cleanup errors; test run will recreate reports when successful.
801
+ }
802
+ }
803
+ }
804
+
805
+ export function runTest(argv) {
806
+ const args = {
807
+ target: process.cwd(),
808
+ config: null,
809
+ profile: '',
810
+ firebaseConfig: null,
811
+ docker: false,
812
+ dockerRebuild: false,
813
+ failFast: false,
814
+ ci: false,
815
+ unit: false,
816
+ integration: false,
817
+ e2e: false,
818
+ staging: false,
819
+ full: false,
820
+ logRouting: true,
821
+ infraLogs: 'compact',
822
+ infraLogFile: 'out/tests/infra/emulator.log',
823
+ suiteLogFile: 'out/tests/suite/output.log',
824
+ logAppend: false,
825
+ };
826
+
827
+ for (let i = 0; i < argv.length; i += 1) {
828
+ const token = argv[i];
829
+ if (token === '--target') {
830
+ args.target = resolve(argv[i + 1] ?? '.');
831
+ i += 1;
832
+ continue;
833
+ }
834
+ if (token === '--config') {
835
+ args.config = resolve(argv[i + 1] ?? 'firestack.config.json');
836
+ i += 1;
837
+ continue;
838
+ }
839
+ if (token === '--profile') {
840
+ args.profile = String(argv[i + 1] ?? '').trim();
841
+ i += 1;
842
+ continue;
843
+ }
844
+ if (token === '--firebase-config') {
845
+ args.firebaseConfig = String(argv[i + 1] ?? '').trim();
846
+ i += 1;
847
+ continue;
848
+ }
849
+ if (token === '--docker') { args.docker = true; continue; }
850
+ if (token === '--docker-rebuild') { args.dockerRebuild = true; continue; }
851
+ if (token === '--fail-fast') { args.failFast = true; continue; }
852
+ if (token === '--ci') { args.ci = true; continue; }
853
+ if (token === '--unit') { args.unit = true; continue; }
854
+ if (token === '--integration') { args.integration = true; continue; }
855
+ if (token === '--e2e') { args.e2e = true; continue; }
856
+ if (token === '--staging') { args.staging = true; continue; }
857
+ if (token === '--full') { args.full = true; continue; }
858
+ if (token === '--infra-logs') {
859
+ args.infraLogs = String(argv[i + 1] ?? '').trim().toLowerCase();
860
+ i += 1;
861
+ continue;
862
+ }
863
+ if (token === '--infra-log-file') {
864
+ args.infraLogFile = String(argv[i + 1] ?? args.infraLogFile).trim() || args.infraLogFile;
865
+ i += 1;
866
+ continue;
867
+ }
868
+ if (token === '--suite-log-file') {
869
+ args.suiteLogFile = String(argv[i + 1] ?? args.suiteLogFile).trim() || args.suiteLogFile;
870
+ i += 1;
871
+ continue;
872
+ }
873
+ if (token === '--log-append') { args.logAppend = true; continue; }
874
+ if (token === '--no-log-routing') { args.logRouting = false; continue; }
875
+ if (token === '-h' || token === '--help') {
876
+ printHelp();
877
+ process.exit(0);
878
+ }
879
+ throw new Error(`unknown argument: ${token}`);
880
+ }
881
+
882
+ const { data: config } = loadProjectConfig(args.target, args.config);
883
+ const profileAlias = resolveProfileAlias(args);
884
+ const resolvedFirebaseConfigPath = resolveFirebaseConfigPath(args.target, {
885
+ explicitPath: args.firebaseConfig,
886
+ profileAlias,
887
+ });
888
+ const {
889
+ env: testEnv,
890
+ source: projectSource,
891
+ functionsRuntime,
892
+ } = resolveTestEnv(args.target, {
893
+ profileAlias,
894
+ firebaseConfigPath: resolvedFirebaseConfigPath,
895
+ ignoreAmbientGcloudProject: args.staging,
896
+ });
897
+ const firebaseConfigRuntimePath = resolvedFirebaseConfigPath
898
+ ? (() => {
899
+ const rel = relative(args.target, resolvedFirebaseConfigPath).replaceAll('\\', '/');
900
+ return rel && !rel.startsWith('..') && !isAbsolute(rel) ? rel : resolvedFirebaseConfigPath;
901
+ })()
902
+ : null;
903
+ if (firebaseConfigRuntimePath) {
904
+ testEnv.FIRESTACK_FIREBASE_CONFIG_PATH = firebaseConfigRuntimePath;
905
+ }
906
+ const commands = config.test?.commands ?? {};
907
+ const key = mapCommandKey(args);
908
+ const firestackCliRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
909
+ const internalRunnerBin = args.docker
910
+ ? '/firestack-cli/bin/firestack.mjs'
911
+ : resolve(firestackCliRoot, 'bin/firestack.mjs');
912
+ const logRouterScriptPath = args.docker
913
+ ? '/firestack-cli/scripts/cli/internal-log-router.mjs'
914
+ : resolve(firestackCliRoot, 'scripts/cli/internal-log-router.mjs');
915
+ if (!new Set(['compact', 'verbose', 'quiet']).has(args.infraLogs)) {
916
+ throw new Error(`invalid --infra-logs value "${args.infraLogs}" (expected compact|verbose|quiet)`);
917
+ }
918
+ const configuredCommand = commands[key]
919
+ ?? (key === 'ciFull' ? commands.ci : null)
920
+ ?? (key === 'ciFailFastFull' ? (commands.ciFailFast ?? commands.ci) : null)
921
+ ?? (key === 'ciFailFast' ? commands.ci : null);
922
+ const commandFirebaseConfigPath = args.docker
923
+ ? firebaseConfigRuntimePath
924
+ : resolvedFirebaseConfigPath;
925
+ let command = applyFirebaseConfigToCommand(
926
+ rewriteFirebaseCliInvocations(
927
+ rewriteInternalFirestackInvocations(configuredCommand, internalRunnerBin)
928
+ ),
929
+ commandFirebaseConfigPath
930
+ );
931
+ if (!command) {
932
+ throw new Error(`missing test command "${key}" in firestack.config.json`);
933
+ }
934
+ if (key === 'ciFull' || key === 'ciFailFastFull') {
935
+ const upgradedCommand = promoteCiCommandToFullE2E(command);
936
+ if (upgradedCommand !== command) {
937
+ command = upgradedCommand;
938
+ console.log('[firestack] ci --full enabled: promoting internal E2E stage from smoke to full.');
939
+ } else {
940
+ console.warn('[firestack] ci --full was requested, but no internal run-e2e smoke stage was found in CI command.');
941
+ }
942
+ }
943
+ if ((key === 'ciFailFast' || key === 'ciFailFastFull') && !commands.ciFailFast) {
944
+ console.log('[firestack] ciFailFast command not found; falling back to "ci" command from config.');
945
+ }
946
+
947
+ if (projectSource) {
948
+ console.log(
949
+ `[firestack] using Firebase project "${projectSource.projectId}" (alias "${projectSource.alias}") from .firebaserc`
950
+ );
951
+ }
952
+ if (args.staging && testEnv.GCLOUD_PROJECT?.trim() && projectSource?.projectId
953
+ && testEnv.GCLOUD_PROJECT.trim() !== projectSource.projectId) {
954
+ throw new Error(
955
+ `[test:staging] refusing to run with GCLOUD_PROJECT=${testEnv.GCLOUD_PROJECT.trim()} while .firebaserc alias "${profileAlias}" resolves to ${projectSource.projectId}.`
956
+ );
957
+ }
958
+ if (resolvedFirebaseConfigPath) {
959
+ console.log(`[firestack] using Firebase config "${resolvedFirebaseConfigPath}" for profile "${profileAlias}"`);
960
+ }
961
+ if (Array.isArray(functionsRuntime?.loadedFiles) && functionsRuntime.loadedFiles.length > 0) {
962
+ const listed = functionsRuntime.loadedFiles.map((path) => {
963
+ const rel = relative(args.target, path).replaceAll('\\', '/');
964
+ return rel && !rel.startsWith('..') && !isAbsolute(rel) ? rel : path;
965
+ });
966
+ console.log(`[firestack] merged Functions runtime env from: ${listed.join(', ')}`);
967
+ }
968
+
969
+ const externalBaseUrl = testEnv.E2E_BASE_URL?.trim();
970
+ const projectId = testEnv.GCLOUD_PROJECT?.trim();
971
+ const firebaseConfigArg = resolvedFirebaseConfigPath
972
+ ? ` --config ${escapeShell(resolvedFirebaseConfigPath)}`
973
+ : '';
974
+ const nonDockerLogPrefix = '[test:local]';
975
+
976
+ if (isCiKey(key)) {
977
+ assertNoExternalBaseUrlForCi(testEnv, nonDockerLogPrefix);
978
+ } else if (isE2eKey(key)) {
979
+ validateExternalBaseUrl(externalBaseUrl, testEnv, nonDockerLogPrefix);
980
+ } else if (isStagingKey(key)) {
981
+ validateStagingBaseUrl(testEnv.E2E_BASE_URL ?? 'https://staging.presentgoal.com', testEnv, nonDockerLogPrefix);
982
+ }
983
+
984
+ if (!args.docker) {
985
+ let nonDockerCommand = command;
986
+ const isE2e = isE2eKey(key);
987
+ if (isE2e && !externalBaseUrl && !commandIncludesFirebaseEmulatorsExec(nonDockerCommand)) {
988
+ if (!projectId) {
989
+ throw new Error(
990
+ '[test:e2e] missing GCLOUD_PROJECT for emulator-backed E2E. Set it explicitly or configure .firebaserc.'
991
+ );
992
+ }
993
+ const runFunctionsBuild = `node ${escapeShell(internalRunnerBin)} internal run-functions-build`;
994
+ nonDockerCommand =
995
+ `${runFunctionsBuild} && firebase${firebaseConfigArg} emulators:exec --project ${escapeShell(projectId)} ${escapeShell(command)}`;
996
+ }
997
+ clearExpectedJUnitReports(args.target, key);
998
+ const routedCommand = args.logRouting
999
+ ? buildLogRoutedCommand(nonDockerCommand, {
1000
+ routerScriptPath: logRouterScriptPath,
1001
+ mode: args.infraLogs,
1002
+ infraLogFile: args.infraLogFile,
1003
+ suiteLogFile: args.suiteLogFile,
1004
+ appendLogs: args.logAppend,
1005
+ })
1006
+ : nonDockerCommand;
1007
+ const status = runShell(args.target, routedCommand, key, testEnv);
1008
+ printTestSummary(args.target, key);
1009
+ process.exit(status);
1010
+ }
1011
+
1012
+ const dockerConfig = config.test?.docker ?? {};
1013
+ const logPrefix = buildDockerLogPrefix(key);
1014
+ const passThrough = Array.isArray(dockerConfig.passThroughEnv) ? dockerConfig.passThroughEnv : [];
1015
+ const dockerEnvNames = Array.from(new Set([
1016
+ ...passThrough,
1017
+ 'FIRESTACK_FIREBASE_CONFIG_PATH',
1018
+ ...(functionsRuntime?.keys ?? []),
1019
+ ]));
1020
+
1021
+ if (isCiKey(key)) {
1022
+ assertNoExternalBaseUrlForCi(testEnv, logPrefix);
1023
+ } else if (isE2eKey(key)) {
1024
+ validateExternalBaseUrl(externalBaseUrl, testEnv, logPrefix);
1025
+ } else if (isStagingKey(key)) {
1026
+ const configuredStagingProjectId = typeof dockerConfig.stagingProjectId === 'string'
1027
+ ? dockerConfig.stagingProjectId.trim()
1028
+ : '';
1029
+ if (configuredStagingProjectId) {
1030
+ requireProject(configuredStagingProjectId, testEnv.GCLOUD_PROJECT ?? configuredStagingProjectId, logPrefix);
1031
+ } else if (!testEnv.GCLOUD_PROJECT?.trim()) {
1032
+ throw new Error(
1033
+ `${logPrefix} missing GCLOUD_PROJECT. Set it explicitly or configure .firebaserc (alias "default" or FIREBASE_ALIAS).`
1034
+ );
1035
+ }
1036
+ validateStagingBaseUrl(testEnv.E2E_BASE_URL ?? 'https://staging.presentgoal.com', testEnv, logPrefix);
1037
+ }
1038
+
1039
+ const writablePaths = resolveDockerWritablePaths(args.target, key, command, dockerConfig, logPrefix);
1040
+ const task = createDockerTask({
1041
+ cwd: args.target,
1042
+ logPrefix,
1043
+ dockerConfig: {
1044
+ ...dockerConfig,
1045
+ writablePaths,
1046
+ },
1047
+ env: testEnv,
1048
+ firebaseConfigPath: resolvedFirebaseConfigPath,
1049
+ forceRebuild: args.dockerRebuild,
1050
+ });
1051
+ task.prepare();
1052
+
1053
+ const bootstrapCommand = dockerConfig.bootstrapCommand ?? defaultBootstrapCommand();
1054
+ if (isE2eKey(key) && !externalBaseUrl && !projectId) {
1055
+ throw new Error(
1056
+ `${logPrefix} missing GCLOUD_PROJECT for emulator-backed E2E. Set it explicitly or configure .firebaserc.`
1057
+ );
1058
+ }
1059
+ const dockerFirebaseConfigArg = firebaseConfigRuntimePath
1060
+ ? ` --config ${escapeShell(firebaseConfigRuntimePath)}`
1061
+ : '';
1062
+ const dockerRunFunctionsBuild = `node ${escapeShell(internalRunnerBin)} internal run-functions-build`;
1063
+ const dockerSuiteCommand = isE2eKey(key) && !externalBaseUrl
1064
+ ? `${dockerRunFunctionsBuild} && firebase${dockerFirebaseConfigArg} emulators:exec --project ${escapeShell(projectId)} ${escapeShell(command)}`
1065
+ : command;
1066
+ const setup = [];
1067
+ if (bootstrapCommand) setup.push(bootstrapCommand);
1068
+ if (dockerConfig.installEveryRun === true) {
1069
+ setup.push(dockerConfig.installCommand ?? 'npm ci');
1070
+ }
1071
+ setup.push(dockerSuiteCommand);
1072
+ const runnableCommand = args.logRouting
1073
+ ? buildLogRoutedCommand(setup.join(' && '), {
1074
+ routerScriptPath: logRouterScriptPath,
1075
+ mode: args.infraLogs,
1076
+ infraLogFile: args.infraLogFile,
1077
+ suiteLogFile: args.suiteLogFile,
1078
+ appendLogs: args.logAppend,
1079
+ })
1080
+ : setup.join(' && ');
1081
+
1082
+ console.log(`${logPrefix} image: ${task.image}`);
1083
+ console.log(`${logPrefix} node_modules volume: ${task.nodeModulesVolume}`);
1084
+ console.log(`${logPrefix} emulator cache volume: ${task.emulatorCacheVolume}`);
1085
+
1086
+ clearExpectedJUnitReports(args.target, key);
1087
+ const status = task.run({
1088
+ command: runnableCommand,
1089
+ envNames: dockerEnvNames,
1090
+ extraArgs: ['-v', `${firestackCliRoot}:/firestack-cli:ro`],
1091
+ });
1092
+ printTestSummary(args.target, key);
1093
+ process.exit(status);
1094
+ }