@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,168 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { loadProjectConfig } from './config.mjs';
5
+
6
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
7
+ const TEMPLATE_DIR = join(ROOT, 'templates');
8
+
9
+ function printHelp() {
10
+ console.log('Usage: firestack env [--profile <alias>] [--development] [--staging] [--production] [--all] [--force] [--target <dir>] [--config <path>]');
11
+ }
12
+
13
+ function normalizeProfileAlias(name) {
14
+ const trimmed = String(name ?? '').trim().toLowerCase();
15
+ if (!trimmed) return '';
16
+ if (trimmed === 'development') return 'default';
17
+ return trimmed;
18
+ }
19
+
20
+ function readFirebaseProjects(targetDir) {
21
+ const rcPath = resolve(targetDir, '.firebaserc');
22
+ if (!existsSync(rcPath)) return {};
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(readFileSync(rcPath, 'utf8'));
26
+ } catch {
27
+ throw new Error(`invalid JSON in ${rcPath}`);
28
+ }
29
+ const projects = parsed?.projects;
30
+ if (!projects || typeof projects !== 'object') return {};
31
+
32
+ const normalized = {};
33
+ for (const [alias, projectId] of Object.entries(projects)) {
34
+ if (typeof projectId !== 'string' || !projectId.trim()) continue;
35
+ const profileAlias = normalizeProfileAlias(alias);
36
+ if (!profileAlias) continue;
37
+ normalized[profileAlias] = projectId.trim();
38
+ }
39
+ return normalized;
40
+ }
41
+
42
+ function resolveFallbackProfiles(configProfiles = {}, firebaseProjects = {}) {
43
+ const aliasesFromRc = Object.keys(firebaseProjects);
44
+ if (aliasesFromRc.length > 0) return aliasesFromRc;
45
+ const aliasesFromConfig = Object.keys(configProfiles).map((name) => normalizeProfileAlias(name)).filter(Boolean);
46
+ if (aliasesFromConfig.length > 0) return [...new Set(aliasesFromConfig)];
47
+ return ['default'];
48
+ }
49
+
50
+ function resolveTemplateVariant(profileAlias) {
51
+ if (profileAlias === 'staging') return 'staging';
52
+ if (profileAlias === 'production') return 'production';
53
+ return 'default';
54
+ }
55
+
56
+ function buildGeneratedProfile(profileAlias) {
57
+ const variant = resolveTemplateVariant(profileAlias);
58
+ const files = [
59
+ { target: `.env.${profileAlias}`, template: `env/.env.${variant}.example` },
60
+ { target: `.env.test.${profileAlias}`, template: `env/.env.test.${variant}.example` },
61
+ ];
62
+ return { files };
63
+ }
64
+
65
+ function resolveProfileDefinition(profileAlias, configProfiles) {
66
+ const explicit = configProfiles[profileAlias];
67
+ if (explicit && typeof explicit === 'object') return explicit;
68
+ if (profileAlias === 'default' && configProfiles.development && typeof configProfiles.development === 'object') {
69
+ return configProfiles.development;
70
+ }
71
+ return buildGeneratedProfile(profileAlias);
72
+ }
73
+
74
+ function upsertProjectId(content, projectId) {
75
+ if (!projectId) return content;
76
+ if (/^GCLOUD_PROJECT=.*/m.test(content)) {
77
+ return content.replace(/^GCLOUD_PROJECT=.*/m, `GCLOUD_PROJECT=${projectId}`);
78
+ }
79
+ const prefix = content.endsWith('\n') || content.length === 0 ? '' : '\n';
80
+ return `${content}${prefix}GCLOUD_PROJECT=${projectId}\n`;
81
+ }
82
+
83
+ export function runEnv(argv) {
84
+ const args = {
85
+ target: process.cwd(),
86
+ force: false,
87
+ config: null,
88
+ all: false,
89
+ selectedProfiles: new Set(),
90
+ };
91
+
92
+ for (let i = 0; i < argv.length; i += 1) {
93
+ const token = argv[i];
94
+ if (token === '--profile') {
95
+ const alias = normalizeProfileAlias(argv[i + 1] ?? '');
96
+ if (!alias) throw new Error('missing value for --profile');
97
+ args.selectedProfiles.add(alias);
98
+ i += 1;
99
+ continue;
100
+ }
101
+ if (token === '--development') { args.selectedProfiles.add('default'); continue; }
102
+ if (token === '--staging') { args.selectedProfiles.add('staging'); continue; }
103
+ if (token === '--production') { args.selectedProfiles.add('production'); continue; }
104
+ if (token === '--all') { args.all = true; continue; }
105
+ if (token === '--target') {
106
+ args.target = resolve(argv[i + 1] ?? '.');
107
+ i += 1;
108
+ continue;
109
+ }
110
+ if (token === '--config') {
111
+ args.config = resolve(argv[i + 1] ?? 'firestack.config.json');
112
+ i += 1;
113
+ continue;
114
+ }
115
+ if (token === '--force') {
116
+ args.force = true;
117
+ continue;
118
+ }
119
+ if (token === '-h' || token === '--help') {
120
+ printHelp();
121
+ process.exit(0);
122
+ }
123
+ throw new Error(`unknown argument: ${token}`);
124
+ }
125
+
126
+ const { data: config } = loadProjectConfig(args.target, args.config);
127
+ const configProfiles = config.env?.profiles ?? {};
128
+ const firebaseProjects = readFirebaseProjects(args.target);
129
+ const fallbackProfiles = resolveFallbackProfiles(configProfiles, firebaseProjects);
130
+ const selectedProfiles = args.all
131
+ ? new Set(fallbackProfiles)
132
+ : (args.selectedProfiles.size > 0 ? args.selectedProfiles : new Set(['default']));
133
+
134
+ for (const profileAlias of selectedProfiles) {
135
+ const profile = resolveProfileDefinition(profileAlias, configProfiles);
136
+ if (!profile) {
137
+ throw new Error(`missing env profile "${profileAlias}" in firestack.config.json`);
138
+ }
139
+ const mappings = Array.isArray(profile.files) ? profile.files : [];
140
+ if (mappings.length === 0) {
141
+ throw new Error(`profile "${profileAlias}" has no files mapping in firestack.config.json`);
142
+ }
143
+
144
+ for (const mapping of mappings) {
145
+ const targetFile = mapping?.target;
146
+ const templateFile = mapping?.template;
147
+ if (!targetFile || !templateFile) {
148
+ throw new Error(`invalid mapping in profile "${profileName}" (expected target/template)`);
149
+ }
150
+
151
+ const source = join(TEMPLATE_DIR, templateFile);
152
+ const destination = resolve(args.target, targetFile);
153
+ if (!existsSync(source)) {
154
+ throw new Error(`missing template in package: ${templateFile}`);
155
+ }
156
+ if (existsSync(destination) && !args.force) {
157
+ console.log(`[firestack] skipped ${destination} (already exists, use --force to overwrite)`);
158
+ continue;
159
+ }
160
+ mkdirSync(dirname(destination), { recursive: true });
161
+ const sourceContent = readFileSync(source, 'utf8');
162
+ const projectId = firebaseProjects[profileAlias] ?? null;
163
+ const nextContent = upsertProjectId(sourceContent, projectId);
164
+ writeFileSync(destination, nextContent, 'utf8');
165
+ console.log(`[firestack] wrote ${destination}`);
166
+ }
167
+ }
168
+ }
@@ -0,0 +1,98 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
4
+ export function parseEnvFile(content) {
5
+ const parsed = {};
6
+ const lines = String(content).split(/\r?\n/);
7
+ for (const line of lines) {
8
+ const trimmed = line.trim();
9
+ if (!trimmed || trimmed.startsWith('#')) continue;
10
+ const idx = trimmed.indexOf('=');
11
+ if (idx <= 0) continue;
12
+ const key = trimmed.slice(0, idx).trim().replace(/^export\s+/, '');
13
+ let value = trimmed.slice(idx + 1).trim();
14
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
15
+ value = value.slice(1, -1);
16
+ }
17
+ parsed[key] = value;
18
+ }
19
+ return parsed;
20
+ }
21
+
22
+ function readJsonStrict(path) {
23
+ try {
24
+ return JSON.parse(readFileSync(path, 'utf8'));
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ function normalizeRelativePath(value) {
31
+ if (typeof value !== 'string') return null;
32
+ const trimmed = value.trim().replace(/\\/g, '/').replace(/^\.?\//, '');
33
+ if (!trimmed || trimmed.startsWith('/') || trimmed.includes('..')) return null;
34
+ return trimmed;
35
+ }
36
+
37
+ export function discoverFunctionsSourcePaths(cwd, firebaseConfigPath = null) {
38
+ const configPath = firebaseConfigPath ?? resolve(cwd, 'firebase.json');
39
+ const firebaseJson = readJsonStrict(configPath);
40
+ const discovered = [];
41
+ const addCandidate = (candidate) => {
42
+ const normalized = normalizeRelativePath(candidate);
43
+ if (!normalized) return;
44
+ if (existsSync(resolve(cwd, normalized, 'package.json'))) {
45
+ discovered.push(normalized);
46
+ }
47
+ };
48
+
49
+ const functionsConfig = firebaseJson?.functions;
50
+ if (typeof functionsConfig === 'string') {
51
+ addCandidate(functionsConfig);
52
+ } else if (Array.isArray(functionsConfig)) {
53
+ for (const entry of functionsConfig) {
54
+ if (typeof entry === 'string') addCandidate(entry);
55
+ else if (entry && typeof entry === 'object') addCandidate(entry.source);
56
+ }
57
+ } else if (functionsConfig && typeof functionsConfig === 'object') {
58
+ addCandidate(functionsConfig.source);
59
+ }
60
+
61
+ if (discovered.length === 0 && existsSync(resolve(cwd, 'functions', 'package.json'))) {
62
+ discovered.push('functions');
63
+ }
64
+
65
+ return [...new Set(discovered)];
66
+ }
67
+
68
+ export function resolveFunctionsRuntimeEnv(cwd, {
69
+ projectId = null,
70
+ firebaseConfigPath = null,
71
+ includeLocal = true,
72
+ } = {}) {
73
+ const sourcePaths = discoverFunctionsSourcePaths(cwd, firebaseConfigPath);
74
+ const merged = {};
75
+ const loadedFiles = [];
76
+ const normalizedProjectId = typeof projectId === 'string' && projectId.trim() ? projectId.trim() : null;
77
+
78
+ for (const sourcePath of sourcePaths) {
79
+ const candidates = [
80
+ resolve(cwd, sourcePath, '.env'),
81
+ ...(normalizedProjectId ? [resolve(cwd, sourcePath, `.env.${normalizedProjectId}`)] : []),
82
+ ...(includeLocal ? [resolve(cwd, sourcePath, '.env.local')] : []),
83
+ ];
84
+
85
+ for (const filePath of candidates) {
86
+ if (!existsSync(filePath)) continue;
87
+ Object.assign(merged, parseEnvFile(readFileSync(filePath, 'utf8')));
88
+ loadedFiles.push(filePath);
89
+ }
90
+ }
91
+
92
+ return {
93
+ env: merged,
94
+ keys: Object.keys(merged),
95
+ loadedFiles,
96
+ sourcePaths,
97
+ };
98
+ }
@@ -0,0 +1,134 @@
1
+ import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
6
+ const TEMPLATE_CONFIG = join(ROOT, 'templates', 'firestack.config.json');
7
+ const TEMPLATE_PLAYWRIGHT_CONFIG = join(ROOT, 'templates', 'playwright.config.mjs');
8
+ const TEMPLATE_DOCKERFILE = join(ROOT, 'templates', 'tests.Dockerfile');
9
+ const TEMPLATE_DOCKERIGNORE = join(ROOT, 'templates', 'dockerignore');
10
+
11
+ function printHelp() {
12
+ console.log('Usage: firestack init [--target <dir>] [--force] [--dry-run]');
13
+ }
14
+
15
+ function ensureOutIgnored(targetDir, dryRun) {
16
+ const gitignorePath = join(targetDir, '.gitignore');
17
+ const desiredEntry = 'out/';
18
+ const hasGitignore = existsSync(gitignorePath);
19
+ const content = hasGitignore ? readFileSync(gitignorePath, 'utf8') : '';
20
+ const normalizedLines = content
21
+ .split(/\r?\n/)
22
+ .map((line) => line.trim())
23
+ .filter(Boolean);
24
+
25
+ const alreadyIgnored = normalizedLines.some((line) => line === desiredEntry || line === 'out' || line === '/out/');
26
+ if (alreadyIgnored) {
27
+ console.log(`[firestack] .gitignore already contains ${desiredEntry}`);
28
+ return;
29
+ }
30
+
31
+ if (dryRun) {
32
+ console.log(`[firestack] would add ${desiredEntry} to ${gitignorePath}`);
33
+ return;
34
+ }
35
+
36
+ const endsWithNewline = content === '' || content.endsWith('\n');
37
+ const prefix = content === '' || endsWithNewline ? '' : '\n';
38
+ const next = `${content}${prefix}${desiredEntry}\n`;
39
+ writeFileSync(gitignorePath, next, 'utf8');
40
+ console.log(`[firestack] updated ${gitignorePath} with ${desiredEntry}`);
41
+ }
42
+
43
+ function ensureDockerignoreEntries(targetDir, dryRun) {
44
+ const dockerignorePath = join(targetDir, '.dockerignore');
45
+ const hasDockerignore = existsSync(dockerignorePath);
46
+ const currentContent = hasDockerignore ? readFileSync(dockerignorePath, 'utf8') : '';
47
+ const desiredEntries = readFileSync(TEMPLATE_DOCKERIGNORE, 'utf8')
48
+ .split(/\r?\n/)
49
+ .map((line) => line.trim())
50
+ .filter((line) => line && !line.startsWith('#'));
51
+
52
+ const existingEntries = new Set(
53
+ currentContent
54
+ .split(/\r?\n/)
55
+ .map((line) => line.trim())
56
+ .filter(Boolean)
57
+ );
58
+ const missing = desiredEntries.filter((entry) => !existingEntries.has(entry));
59
+ if (missing.length === 0) {
60
+ console.log('[firestack] .dockerignore already contains default entries');
61
+ return;
62
+ }
63
+
64
+ if (dryRun) {
65
+ console.log(`[firestack] would add ${missing.length} entries to ${dockerignorePath}`);
66
+ return;
67
+ }
68
+
69
+ const endsWithNewline = currentContent === '' || currentContent.endsWith('\n');
70
+ const prefix = currentContent === '' || endsWithNewline ? '' : '\n';
71
+ const next = `${currentContent}${prefix}${missing.join('\n')}\n`;
72
+ writeFileSync(dockerignorePath, next, 'utf8');
73
+ console.log(`[firestack] updated ${dockerignorePath} with ${missing.length} entries`);
74
+ }
75
+
76
+ export function runInit(argv) {
77
+ const args = {
78
+ target: process.cwd(),
79
+ force: false,
80
+ dryRun: false,
81
+ };
82
+
83
+ for (let i = 0; i < argv.length; i += 1) {
84
+ const token = argv[i];
85
+ if (token === '--target') {
86
+ args.target = resolve(argv[i + 1] ?? '.');
87
+ i += 1;
88
+ continue;
89
+ }
90
+ if (token === '--force') {
91
+ args.force = true;
92
+ continue;
93
+ }
94
+ if (token === '--dry-run') {
95
+ args.dryRun = true;
96
+ continue;
97
+ }
98
+ if (token === '-h' || token === '--help') {
99
+ printHelp();
100
+ process.exit(0);
101
+ }
102
+ throw new Error(`unknown argument: ${token}`);
103
+ }
104
+
105
+ const files = [
106
+ { template: TEMPLATE_CONFIG, relativePath: 'firestack.config.json', label: 'firestack.config.json' },
107
+ { template: TEMPLATE_PLAYWRIGHT_CONFIG, relativePath: 'playwright.config.mjs', label: 'playwright.config.mjs' },
108
+ { template: TEMPLATE_DOCKERFILE, relativePath: 'tests/Dockerfile', label: 'tests/Dockerfile' },
109
+ ];
110
+ let skippedExisting = false;
111
+
112
+ for (const file of files) {
113
+ const destination = join(args.target, file.relativePath);
114
+ if (existsSync(destination) && !args.force) {
115
+ console.log(`[firestack] ${file.label} already exists: ${destination}`);
116
+ skippedExisting = true;
117
+ continue;
118
+ }
119
+ if (!args.dryRun) {
120
+ mkdirSync(dirname(destination), { recursive: true });
121
+ cpSync(file.template, destination, { recursive: false });
122
+ }
123
+ console.log(`[firestack] initialized ${destination}`);
124
+ }
125
+
126
+ if (skippedExisting) {
127
+ console.log('[firestack] use --force to overwrite existing files');
128
+ }
129
+ ensureDockerignoreEntries(args.target, args.dryRun);
130
+ ensureOutIgnored(args.target, args.dryRun);
131
+ if (args.dryRun) {
132
+ console.log('[firestack] dry-run mode: no files were written');
133
+ }
134
+ }
@@ -0,0 +1,105 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ import { runInit } from './init.mjs';
5
+
6
+ function printHelp() {
7
+ console.log('Usage: firestack install [--target <dir>] [--force] [--dry-run]');
8
+ }
9
+
10
+ function mergeDevDependencies(pkg, force, desired) {
11
+ const existing = pkg.devDependencies ?? {};
12
+ const merged = { ...existing };
13
+ const created = [];
14
+ const skipped = [];
15
+ const overwritten = [];
16
+
17
+ for (const [name, version] of Object.entries(desired)) {
18
+ if (!(name in merged)) {
19
+ merged[name] = version;
20
+ created.push(name);
21
+ continue;
22
+ }
23
+ if (merged[name] === version) continue;
24
+ if (force) {
25
+ merged[name] = version;
26
+ overwritten.push(name);
27
+ } else {
28
+ skipped.push(name);
29
+ }
30
+ }
31
+
32
+ pkg.devDependencies = merged;
33
+ return { created, skipped, overwritten };
34
+ }
35
+
36
+ function runInTarget(targetDir, command, args) {
37
+ const result = spawnSync(command, args, {
38
+ cwd: targetDir,
39
+ stdio: 'inherit',
40
+ });
41
+ if (result.error) {
42
+ throw new Error(`failed to execute "${command} ${args.join(' ')}": ${result.error.message}`);
43
+ }
44
+ if ((result.status ?? 1) !== 0) {
45
+ throw new Error(`command failed (${result.status ?? 1}): ${command} ${args.join(' ')}`);
46
+ }
47
+ }
48
+
49
+ export function runInstall(argv) {
50
+ const args = {
51
+ target: process.cwd(),
52
+ force: false,
53
+ dryRun: false,
54
+ };
55
+
56
+ for (let i = 0; i < argv.length; i += 1) {
57
+ const token = argv[i];
58
+ if (token === '--target') {
59
+ args.target = resolve(argv[i + 1] ?? '.');
60
+ i += 1;
61
+ continue;
62
+ }
63
+ if (token === '--force') {
64
+ args.force = true;
65
+ continue;
66
+ }
67
+ if (token === '--dry-run') {
68
+ args.dryRun = true;
69
+ continue;
70
+ }
71
+ if (token === '-h' || token === '--help') {
72
+ printHelp();
73
+ process.exit(0);
74
+ }
75
+ throw new Error(`unknown argument: ${token}`);
76
+ }
77
+
78
+ runInit([ '--target', args.target, ...(args.force ? ['--force'] : []), ...(args.dryRun ? ['--dry-run'] : []) ]);
79
+
80
+ const pkgPath = join(args.target, 'package.json');
81
+ if (!existsSync(pkgPath)) {
82
+ throw new Error(`package.json not found in ${args.target}`);
83
+ }
84
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
85
+ const depsResult = mergeDevDependencies(pkg, args.force, {
86
+ '@playwright/test': '^1.58.2',
87
+ });
88
+
89
+ if (!args.dryRun) {
90
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8');
91
+ runInTarget(args.target, 'npm', ['install']);
92
+ runInTarget(args.target, 'npm', ['exec', 'playwright', 'install', 'chromium']);
93
+ }
94
+
95
+ console.log(`[firestack] devDependencies added: ${depsResult.created.length}`);
96
+ if (depsResult.overwritten.length > 0) {
97
+ console.log(`[firestack] devDependencies overwritten: ${depsResult.overwritten.join(', ')}`);
98
+ }
99
+ if (depsResult.skipped.length > 0) {
100
+ console.log(`[firestack] devDependencies skipped (use --force to overwrite): ${depsResult.skipped.join(', ')}`);
101
+ }
102
+ if (args.dryRun) {
103
+ console.log('[firestack] dry-run mode: no files were written');
104
+ }
105
+ }
@@ -0,0 +1,94 @@
1
+ export function resolveSuite(rawSuite) {
2
+ const suiteArg = (rawSuite ?? 'smoke').trim().toLowerCase();
3
+ return suiteArg === 'full' ? 'full' : 'smoke';
4
+ }
5
+
6
+ export function isOverrideAllowed() {
7
+ return process.env.ALLOW_NON_STAGING_E2E === 'true';
8
+ }
9
+
10
+ function normalizeHost(rawUrl) {
11
+ const url = new URL(rawUrl);
12
+ return url.hostname.toLowerCase();
13
+ }
14
+
15
+ export function validateExternalBaseUrl(baseUrl, logPrefix) {
16
+ if (!baseUrl) return;
17
+ if (isOverrideAllowed()) return;
18
+
19
+ let host;
20
+ try {
21
+ host = normalizeHost(baseUrl);
22
+ } catch {
23
+ throw new Error(`${logPrefix} Invalid E2E_BASE_URL: ${baseUrl}`);
24
+ }
25
+
26
+ const allowedHosts = new Set(['localhost', '127.0.0.1', 'staging.presentgoal.com']);
27
+ if (!allowedHosts.has(host)) {
28
+ throw new Error(
29
+ `${logPrefix} Refusing E2E_BASE_URL host "${host}". Allowed: localhost, 127.0.0.1, staging.presentgoal.com. ` +
30
+ 'Set ALLOW_NON_STAGING_E2E=true to override explicitly.'
31
+ );
32
+ }
33
+ }
34
+
35
+ export function validateStagingBaseUrl(baseUrl, logPrefix) {
36
+ if (!baseUrl) return;
37
+ if (isOverrideAllowed()) return;
38
+
39
+ let host;
40
+ try {
41
+ host = normalizeHost(baseUrl);
42
+ } catch {
43
+ throw new Error(`${logPrefix} Invalid E2E_BASE_URL: ${baseUrl}`);
44
+ }
45
+
46
+ if (host !== 'staging.presentgoal.com') {
47
+ throw new Error(
48
+ `${logPrefix} Refusing E2E_BASE_URL host "${host}" for staging runner. Allowed only: staging.presentgoal.com. ` +
49
+ 'Set ALLOW_NON_STAGING_E2E=true to override explicitly.'
50
+ );
51
+ }
52
+ }
53
+
54
+ export function grepTagForSuite(suite) {
55
+ return suite === 'full' ? null : '@smoke';
56
+ }
57
+
58
+ export function grepInvertTagForSuite() {
59
+ return '@skip';
60
+ }
61
+
62
+ export function buildPlaywrightFilterArgs(suite) {
63
+ const args = [];
64
+ const grepTag = grepTagForSuite(suite);
65
+ if (grepTag) {
66
+ args.push('--grep', grepTag);
67
+ }
68
+ const grepInvertTag = grepInvertTagForSuite();
69
+ if (grepInvertTag) {
70
+ args.push('--grep-invert', grepInvertTag);
71
+ }
72
+ return args;
73
+ }
74
+
75
+ export function buildRunId(raw) {
76
+ const value = raw ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
77
+ return value.replace(/[^a-zA-Z0-9_-]/g, '');
78
+ }
79
+
80
+ export function requireProject(expectedProjectId, currentProjectId, logPrefix) {
81
+ if (currentProjectId !== expectedProjectId) {
82
+ throw new Error(`${logPrefix} Refusing to run with GCLOUD_PROJECT=${currentProjectId}. Expected ${expectedProjectId}.`);
83
+ }
84
+ return currentProjectId;
85
+ }
86
+
87
+ export function resolveStagingCredentials(suite) {
88
+ const emailVar = suite === 'smoke' ? 'E2E_STAGING_SMOKE_EMAIL' : 'E2E_STAGING_FULL_EMAIL';
89
+ const passwordVar = suite === 'smoke' ? 'E2E_STAGING_SMOKE_PASSWORD' : 'E2E_STAGING_FULL_PASSWORD';
90
+ const email = process.env[emailVar] ?? process.env.E2E_STAGING_EMAIL;
91
+ const password = process.env[passwordVar] ?? process.env.E2E_STAGING_PASSWORD;
92
+ const useFixedUser = Boolean(email && password);
93
+ return { emailVar, passwordVar, email, password, useFixedUser };
94
+ }