@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.
- package/CHANGELOG.md +10 -0
- package/README.md +225 -0
- package/bin/firestack.mjs +122 -0
- package/package.json +36 -0
- package/scripts/cli/config-migrate.mjs +129 -0
- package/scripts/cli/config.mjs +14 -0
- package/scripts/cli/docker-init.mjs +102 -0
- package/scripts/cli/docker-runner.mjs +579 -0
- package/scripts/cli/env.mjs +168 -0
- package/scripts/cli/functions-env.mjs +98 -0
- package/scripts/cli/init.mjs +134 -0
- package/scripts/cli/install.mjs +105 -0
- package/scripts/cli/internal-e2e-runner.mjs +94 -0
- package/scripts/cli/internal-log-router.mjs +116 -0
- package/scripts/cli/internal-run-e2e-staging.mjs +49 -0
- package/scripts/cli/internal-run-e2e.mjs +153 -0
- package/scripts/cli/internal-run-functions-build.mjs +91 -0
- package/scripts/cli/internal-run-integration-report.mjs +132 -0
- package/scripts/cli/test.mjs +1094 -0
- package/scripts/publish-package.sh +16 -0
- package/templates/dockerignore +37 -0
- package/templates/env/.env.default.example +4 -0
- package/templates/env/.env.development.example +4 -0
- package/templates/env/.env.production.example +2 -0
- package/templates/env/.env.staging.example +4 -0
- package/templates/env/.env.test.default.example +6 -0
- package/templates/env/.env.test.development.example +6 -0
- package/templates/env/.env.test.production.example +4 -0
- package/templates/env/.env.test.staging.example +8 -0
- package/templates/firestack.config.json +101 -0
- package/templates/playwright.config.mjs +65 -0
- package/templates/tests.Dockerfile +43 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createWriteStream, mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import readline from 'node:readline';
|
|
4
|
+
|
|
5
|
+
function parseArgs(argv) {
|
|
6
|
+
const args = {
|
|
7
|
+
mode: 'compact',
|
|
8
|
+
infraLog: 'out/tests/infra/emulator.log',
|
|
9
|
+
suiteLog: 'out/tests/suite/output.log',
|
|
10
|
+
append: false,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
14
|
+
const token = argv[i];
|
|
15
|
+
if (token === '--mode') {
|
|
16
|
+
args.mode = String(argv[i + 1] ?? '').trim().toLowerCase();
|
|
17
|
+
i += 1;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (token === '--infra-log') {
|
|
21
|
+
args.infraLog = String(argv[i + 1] ?? args.infraLog).trim() || args.infraLog;
|
|
22
|
+
i += 1;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (token === '--suite-log') {
|
|
26
|
+
args.suiteLog = String(argv[i + 1] ?? args.suiteLog).trim() || args.suiteLog;
|
|
27
|
+
i += 1;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (token === '--append') {
|
|
31
|
+
args.append = true;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (token === '--reset') {
|
|
35
|
+
args.append = false;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`unknown argument: ${token}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!new Set(['compact', 'verbose', 'quiet']).has(args.mode)) {
|
|
42
|
+
throw new Error(`invalid --mode "${args.mode}" (expected compact|verbose|quiet)`);
|
|
43
|
+
}
|
|
44
|
+
return args;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function ensureParentDir(path) {
|
|
48
|
+
mkdirSync(dirname(resolve(path)), { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function stripAnsi(text) {
|
|
52
|
+
return text.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isInfraLine(rawLine) {
|
|
56
|
+
const line = stripAnsi(rawLine).trimStart();
|
|
57
|
+
if (/^(i|✔|⚠)\s{2}(functions(?:\[[^\]]+\])?|hosting(?:\[[^\]]+\])?|firestore|auth|emulators|hub|logging|eventarc|tasks|extensions):/.test(line)) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
if (/^i\s{2}Running script:/.test(line)) {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
if (/^⚠\s{2}Script exited unsuccessfully/.test(line)) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
if (/^Serving at port \d+/.test(line)) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
if (/^>\s+\{/.test(line) && line.includes('"firebase-log-type"')) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isInfraImportant(rawLine) {
|
|
76
|
+
const line = stripAnsi(rawLine).trimStart();
|
|
77
|
+
return line.startsWith('⚠') || line.startsWith('Error:') || line.includes(' exited unsuccessfully ');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function shouldWriteInfraToConsole(line, mode) {
|
|
81
|
+
if (mode === 'verbose') return true;
|
|
82
|
+
if (mode === 'quiet') return isInfraImportant(line);
|
|
83
|
+
return isInfraImportant(line);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function main() {
|
|
87
|
+
const args = parseArgs(process.argv.slice(2));
|
|
88
|
+
ensureParentDir(args.infraLog);
|
|
89
|
+
ensureParentDir(args.suiteLog);
|
|
90
|
+
|
|
91
|
+
const fileMode = args.append ? 'a' : 'w';
|
|
92
|
+
const infraStream = createWriteStream(args.infraLog, { flags: fileMode });
|
|
93
|
+
const suiteStream = createWriteStream(args.suiteLog, { flags: fileMode });
|
|
94
|
+
const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
95
|
+
|
|
96
|
+
input.on('line', (line) => {
|
|
97
|
+
const isInfra = isInfraLine(line);
|
|
98
|
+
if (isInfra) {
|
|
99
|
+
infraStream.write(`${line}\n`);
|
|
100
|
+
if (shouldWriteInfraToConsole(line, args.mode)) {
|
|
101
|
+
process.stdout.write(`${line}\n`);
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
suiteStream.write(`${line}\n`);
|
|
107
|
+
process.stdout.write(`${line}\n`);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
input.on('close', () => {
|
|
111
|
+
infraStream.end();
|
|
112
|
+
suiteStream.end();
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
main();
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
buildRunId,
|
|
6
|
+
requireProject,
|
|
7
|
+
resolveStagingCredentials,
|
|
8
|
+
resolveSuite,
|
|
9
|
+
validateStagingBaseUrl,
|
|
10
|
+
} from './internal-e2e-runner.mjs';
|
|
11
|
+
|
|
12
|
+
const suite = resolveSuite(process.argv[2]);
|
|
13
|
+
const runId = buildRunId(process.env.E2E_RUN_ID ?? `stg-${suite}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
14
|
+
const configuredProjectId = process.env.STAGING_PROJECT_ID?.trim();
|
|
15
|
+
const currentProjectId = process.env.GCLOUD_PROJECT?.trim();
|
|
16
|
+
if (!currentProjectId) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
'[test:e2e:staging] missing GCLOUD_PROJECT. Set it explicitly or configure .firebaserc (default alias or FIREBASE_ALIAS).'
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
const projectId = configuredProjectId
|
|
22
|
+
? requireProject(configuredProjectId, currentProjectId, '[test:e2e:staging]')
|
|
23
|
+
: currentProjectId;
|
|
24
|
+
const baseUrl = process.env.E2E_BASE_URL ?? 'https://staging.presentgoal.com';
|
|
25
|
+
validateStagingBaseUrl(baseUrl, '[test:e2e:staging]');
|
|
26
|
+
|
|
27
|
+
const { emailVar, passwordVar, email, password, useFixedUser } = resolveStagingCredentials(suite);
|
|
28
|
+
if (!useFixedUser) {
|
|
29
|
+
console.log('[test:e2e:staging] Running with ephemeral user (no fixed credentials provided).');
|
|
30
|
+
console.log(`[test:e2e:staging] To use fixed user set ${emailVar}/${passwordVar} or E2E_STAGING_EMAIL/E2E_STAGING_PASSWORD.`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const currentDir = dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
const e2eRunnerPath = join(currentDir, 'internal-run-e2e.mjs');
|
|
35
|
+
|
|
36
|
+
const result = spawnSync('node', [e2eRunnerPath, suite], {
|
|
37
|
+
stdio: 'inherit',
|
|
38
|
+
env: {
|
|
39
|
+
...process.env,
|
|
40
|
+
E2E_BASE_URL: baseUrl,
|
|
41
|
+
E2E_SUITE: suite,
|
|
42
|
+
E2E_CLEANUP: process.env.E2E_CLEANUP ?? 'false',
|
|
43
|
+
E2E_RUN_ID: runId,
|
|
44
|
+
GCLOUD_PROJECT: projectId,
|
|
45
|
+
...(useFixedUser ? { E2E_EMAIL: email, E2E_PASSWORD: password } : {}),
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
process.exit(result.status ?? 1);
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
3
|
+
import { buildPlaywrightFilterArgs, buildRunId, resolveSuite, validateExternalBaseUrl } from './internal-e2e-runner.mjs';
|
|
4
|
+
|
|
5
|
+
const hasPlaywrightPkg = existsSync('node_modules/@playwright/test/package.json');
|
|
6
|
+
const playwrightBin = process.platform === 'win32'
|
|
7
|
+
? 'node_modules/.bin/playwright.cmd'
|
|
8
|
+
: 'node_modules/.bin/playwright';
|
|
9
|
+
|
|
10
|
+
if (!hasPlaywrightPkg || !existsSync(playwrightBin)) {
|
|
11
|
+
console.log('[test:e2e] @playwright/test is not installed. Skipping E2E.');
|
|
12
|
+
console.log('[test:e2e] To enable: npm install -D @playwright/test && npx playwright install');
|
|
13
|
+
process.exit(0);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function waitForUrl(url, timeoutMs = 40_000) {
|
|
17
|
+
const startedAt = Date.now();
|
|
18
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch(url);
|
|
21
|
+
if (res.ok || res.status === 404) return;
|
|
22
|
+
} catch {
|
|
23
|
+
// retry
|
|
24
|
+
}
|
|
25
|
+
// eslint-disable-next-line no-await-in-loop
|
|
26
|
+
await new Promise((r) => setTimeout(r, 350));
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`Timeout waiting for app at ${url}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function detectViteUrlFromOutput(line) {
|
|
32
|
+
const clean = line.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
|
|
33
|
+
const direct = clean.match(/(https?:\/\/127\.0\.0\.1:\d+)/);
|
|
34
|
+
if (direct) return direct[1];
|
|
35
|
+
const local = clean.match(/Local:\s+(https?:\/\/[^\s]+)/);
|
|
36
|
+
return local ? local[1] : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function startDevServer() {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
let settled = false;
|
|
42
|
+
let stdoutBuffer = '';
|
|
43
|
+
let stderrBuffer = '';
|
|
44
|
+
const devServer = spawn('npm', ['run', 'dev', '--', '--host', '127.0.0.1', '--port', '5173'], {
|
|
45
|
+
detached: process.platform !== 'win32',
|
|
46
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
47
|
+
env: {
|
|
48
|
+
...process.env,
|
|
49
|
+
VITE_USE_FIREBASE_EMULATOR: process.env.VITE_USE_FIREBASE_EMULATOR ?? 'true',
|
|
50
|
+
VITE_FIREBASE_EMULATOR_HOST: process.env.VITE_FIREBASE_EMULATOR_HOST ?? '127.0.0.1',
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const onData = (chunk) => {
|
|
55
|
+
const text = chunk.toString();
|
|
56
|
+
process.stdout.write(text);
|
|
57
|
+
if (settled) return;
|
|
58
|
+
stdoutBuffer = `${stdoutBuffer}${text}`.slice(-16_384);
|
|
59
|
+
const foundUrl = detectViteUrlFromOutput(stdoutBuffer);
|
|
60
|
+
if (foundUrl) {
|
|
61
|
+
settled = true;
|
|
62
|
+
resolve({ devServer, baseUrl: foundUrl });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// Fallback: if Vite is ready but URL line is styled/fragmented unexpectedly,
|
|
66
|
+
// use the known host/port we launch with.
|
|
67
|
+
if (/VITE\s+v\d/i.test(stdoutBuffer) && /\bready in\b/i.test(stdoutBuffer)) {
|
|
68
|
+
settled = true;
|
|
69
|
+
resolve({ devServer, baseUrl: 'http://127.0.0.1:5173' });
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const onError = (chunk) => {
|
|
74
|
+
const text = chunk.toString();
|
|
75
|
+
process.stderr.write(text);
|
|
76
|
+
stderrBuffer = `${stderrBuffer}${text}`.slice(-8_192);
|
|
77
|
+
if (!settled && /error/i.test(text)) {
|
|
78
|
+
settled = true;
|
|
79
|
+
reject(new Error(`[test:e2e] failed to start app: ${stderrBuffer.trim()}`));
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
devServer.stdout.on('data', onData);
|
|
84
|
+
devServer.stderr.on('data', onError);
|
|
85
|
+
devServer.on('exit', (code) => {
|
|
86
|
+
if (!settled) {
|
|
87
|
+
settled = true;
|
|
88
|
+
reject(new Error(`[test:e2e] dev server exited before startup (code=${code ?? 'null'})`));
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function stopDevServer(devServer) {
|
|
95
|
+
if (!devServer || devServer.killed) return;
|
|
96
|
+
try {
|
|
97
|
+
if (process.platform !== 'win32' && typeof devServer.pid === 'number') {
|
|
98
|
+
process.kill(-devServer.pid, 'SIGTERM');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
devServer.kill('SIGTERM');
|
|
102
|
+
} catch {
|
|
103
|
+
// ignore teardown errors
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function main() {
|
|
108
|
+
const suite = resolveSuite(process.argv[2]);
|
|
109
|
+
const filterArgs = buildPlaywrightFilterArgs(suite);
|
|
110
|
+
const runId = buildRunId(process.env.E2E_RUN_ID);
|
|
111
|
+
const externalBaseUrl = process.env.E2E_BASE_URL?.trim();
|
|
112
|
+
validateExternalBaseUrl(externalBaseUrl, '[test:e2e]');
|
|
113
|
+
const shouldStartDevServer = !externalBaseUrl;
|
|
114
|
+
const projectId = process.env.GCLOUD_PROJECT?.trim();
|
|
115
|
+
let devServer = null;
|
|
116
|
+
let baseUrl = externalBaseUrl ?? '';
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
if (shouldStartDevServer) {
|
|
120
|
+
console.log('[test:e2e] starting app with Vite (prefers 5173; auto-fallback enabled)...');
|
|
121
|
+
const started = await startDevServer();
|
|
122
|
+
devServer = started.devServer;
|
|
123
|
+
baseUrl = started.baseUrl;
|
|
124
|
+
await waitForUrl(baseUrl);
|
|
125
|
+
} else {
|
|
126
|
+
console.log(`[test:e2e] using existing app at ${baseUrl}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const cleanup = process.env.E2E_CLEANUP ?? (shouldStartDevServer ? 'true' : 'false');
|
|
130
|
+
const result = spawnSync(playwrightBin, ['test', '--project=chromium', ...filterArgs, 'tests/e2e'], {
|
|
131
|
+
stdio: 'inherit',
|
|
132
|
+
env: {
|
|
133
|
+
...process.env,
|
|
134
|
+
E2E_SUITE: suite,
|
|
135
|
+
E2E_RUN_ID: runId,
|
|
136
|
+
E2E_CLEANUP: cleanup,
|
|
137
|
+
E2E_BASE_URL: baseUrl,
|
|
138
|
+
...(projectId ? { GCLOUD_PROJECT: projectId } : {}),
|
|
139
|
+
FIREBASE_AUTH_EMULATOR_HOST: process.env.FIREBASE_AUTH_EMULATOR_HOST ?? '127.0.0.1:9099',
|
|
140
|
+
FIRESTORE_EMULATOR_HOST: process.env.FIRESTORE_EMULATOR_HOST ?? '127.0.0.1:8080',
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
process.exit(result.status ?? 1);
|
|
145
|
+
} finally {
|
|
146
|
+
stopDevServer(devServer);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
main().catch((error) => {
|
|
151
|
+
console.error('[test:e2e] failed to run e2e:', error);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
function readJsonStrict(path) {
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
8
|
+
} catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizeRelativePath(value) {
|
|
14
|
+
if (typeof value !== 'string') return null;
|
|
15
|
+
const normalized = value.trim().replace(/\\/g, '/').replace(/^\.?\//, '');
|
|
16
|
+
if (!normalized || normalized.startsWith('/') || normalized.includes('..')) return null;
|
|
17
|
+
return normalized;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function resolveFirebaseConfigPath(cwd) {
|
|
21
|
+
const configured = process.env.FIRESTACK_FIREBASE_CONFIG_PATH?.trim();
|
|
22
|
+
if (configured) {
|
|
23
|
+
const candidate = resolve(cwd, configured);
|
|
24
|
+
if (!existsSync(candidate)) {
|
|
25
|
+
throw new Error(`[test:functions:build] firebase config not found: ${candidate}`);
|
|
26
|
+
}
|
|
27
|
+
return candidate;
|
|
28
|
+
}
|
|
29
|
+
const fallback = resolve(cwd, 'firebase.json');
|
|
30
|
+
return existsSync(fallback) ? fallback : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function discoverFunctionsPaths(cwd, firebaseConfigPath) {
|
|
34
|
+
const discovered = [];
|
|
35
|
+
const addCandidate = (candidate) => {
|
|
36
|
+
const normalized = normalizeRelativePath(candidate);
|
|
37
|
+
if (!normalized) return;
|
|
38
|
+
if (existsSync(resolve(cwd, normalized, 'package.json'))) {
|
|
39
|
+
discovered.push(normalized);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (firebaseConfigPath) {
|
|
44
|
+
const firebaseJson = readJsonStrict(firebaseConfigPath);
|
|
45
|
+
const functionsConfig = firebaseJson?.functions;
|
|
46
|
+
if (typeof functionsConfig === 'string') {
|
|
47
|
+
addCandidate(functionsConfig);
|
|
48
|
+
} else if (Array.isArray(functionsConfig)) {
|
|
49
|
+
for (const entry of functionsConfig) {
|
|
50
|
+
if (typeof entry === 'string') addCandidate(entry);
|
|
51
|
+
else if (entry && typeof entry === 'object') addCandidate(entry.source);
|
|
52
|
+
}
|
|
53
|
+
} else if (functionsConfig && typeof functionsConfig === 'object') {
|
|
54
|
+
addCandidate(functionsConfig.source);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (discovered.length === 0 && existsSync(resolve(cwd, 'functions', 'package.json'))) {
|
|
59
|
+
discovered.push('functions');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return [...new Set(discovered)];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function runBuild(cwd, modulePath) {
|
|
66
|
+
console.log(`[test:functions:build] building ${modulePath}`);
|
|
67
|
+
const result = spawnSync('npm', ['--prefix', modulePath, 'run', 'build'], {
|
|
68
|
+
cwd,
|
|
69
|
+
stdio: 'inherit',
|
|
70
|
+
env: process.env,
|
|
71
|
+
});
|
|
72
|
+
if (result.error) {
|
|
73
|
+
throw new Error(`[test:functions:build] failed to run npm build in ${modulePath}: ${result.error.message}`);
|
|
74
|
+
}
|
|
75
|
+
if ((result.status ?? 1) !== 0) {
|
|
76
|
+
process.exit(result.status ?? 1);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const cwd = process.cwd();
|
|
81
|
+
const firebaseConfigPath = resolveFirebaseConfigPath(cwd);
|
|
82
|
+
const functionPaths = discoverFunctionsPaths(cwd, firebaseConfigPath);
|
|
83
|
+
|
|
84
|
+
if (functionPaths.length === 0) {
|
|
85
|
+
console.log('[test:functions:build] no functions modules found. skipping build step.');
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const modulePath of functionPaths) {
|
|
90
|
+
runBuild(cwd, modulePath);
|
|
91
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const OUT_DIR = 'out/tests/integration';
|
|
6
|
+
const SERIAL_XML = `${OUT_DIR}/serial.junit.xml`;
|
|
7
|
+
const PARALLEL_XML = `${OUT_DIR}/parallel.junit.xml`;
|
|
8
|
+
const COMBINED_XML = `${OUT_DIR}/junit.xml`;
|
|
9
|
+
const SERIAL_MARKER_RE = /@test-mode\s+serial/i;
|
|
10
|
+
|
|
11
|
+
function listIntegrationTests(dir = 'tests/integration') {
|
|
12
|
+
const entries = readdirSync(dir);
|
|
13
|
+
const files = [];
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
const fullPath = join(dir, entry);
|
|
16
|
+
const stats = statSync(fullPath);
|
|
17
|
+
if (stats.isDirectory()) {
|
|
18
|
+
if (entry.startsWith('_')) continue;
|
|
19
|
+
files.push(...listIntegrationTests(fullPath));
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (entry.endsWith('.test.ts')) files.push(fullPath);
|
|
23
|
+
}
|
|
24
|
+
return files.sort();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getTestExecutionMode(filePath) {
|
|
28
|
+
const source = readFileSync(filePath, 'utf8');
|
|
29
|
+
return SERIAL_MARKER_RE.test(source) ? 'serial' : 'concurrent';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function runNodeTest({ concurrency, destination, tests }) {
|
|
33
|
+
const args = [
|
|
34
|
+
'--test',
|
|
35
|
+
'--experimental-strip-types',
|
|
36
|
+
'--test-reporter=spec',
|
|
37
|
+
'--test-reporter=junit',
|
|
38
|
+
'--test-reporter-destination=stdout',
|
|
39
|
+
`--test-reporter-destination=${destination}`,
|
|
40
|
+
`--test-concurrency=${concurrency}`,
|
|
41
|
+
...tests,
|
|
42
|
+
];
|
|
43
|
+
return spawnSync('node', args, { stdio: 'inherit' });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function emptyJUnitXml() {
|
|
47
|
+
return '<?xml version="1.0" encoding="utf-8"?>\n<testsuites>\n</testsuites>\n';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function extractInnerTestSuites(xml) {
|
|
51
|
+
return xml
|
|
52
|
+
.replace(/^<\?xml[^>]*>\s*/m, '')
|
|
53
|
+
.replace(/^\s*<testsuites>\s*/m, '')
|
|
54
|
+
.replace(/\s*<\/testsuites>\s*$/m, '')
|
|
55
|
+
.trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function combineJunit(serialPath, parallelPath, outPath) {
|
|
59
|
+
const serialXml = readFileSync(serialPath, 'utf8');
|
|
60
|
+
const parallelXml = readFileSync(parallelPath, 'utf8');
|
|
61
|
+
const serialInner = extractInnerTestSuites(serialXml);
|
|
62
|
+
const parallelInner = extractInnerTestSuites(parallelXml);
|
|
63
|
+
const merged = ['<?xml version="1.0" encoding="utf-8"?>', '<testsuites>', serialInner, parallelInner, '</testsuites>', '']
|
|
64
|
+
.join('\n');
|
|
65
|
+
writeFileSync(outPath, merged, 'utf8');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function ensureJUnitFile(path) {
|
|
69
|
+
try {
|
|
70
|
+
readFileSync(path, 'utf8');
|
|
71
|
+
} catch {
|
|
72
|
+
writeFileSync(path, emptyJUnitXml(), 'utf8');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getResultStatus(result) {
|
|
77
|
+
if (!result) return 1;
|
|
78
|
+
if (result.error) {
|
|
79
|
+
console.error(`[test:integration:report] failed to execute node test: ${result.error.message}`);
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
return result.status ?? 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function main() {
|
|
86
|
+
mkdirSync(OUT_DIR, { recursive: true });
|
|
87
|
+
const allTests = listIntegrationTests();
|
|
88
|
+
const serialTests = [];
|
|
89
|
+
const parallelTests = [];
|
|
90
|
+
let serialStatus = 0;
|
|
91
|
+
let parallelStatus = 0;
|
|
92
|
+
|
|
93
|
+
for (const file of allTests) {
|
|
94
|
+
if (getTestExecutionMode(file) === 'serial') serialTests.push(file);
|
|
95
|
+
else parallelTests.push(file);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (serialTests.length > 0) {
|
|
99
|
+
const serialResult = runNodeTest({
|
|
100
|
+
concurrency: 1,
|
|
101
|
+
destination: SERIAL_XML,
|
|
102
|
+
tests: serialTests,
|
|
103
|
+
});
|
|
104
|
+
ensureJUnitFile(SERIAL_XML);
|
|
105
|
+
serialStatus = getResultStatus(serialResult);
|
|
106
|
+
} else {
|
|
107
|
+
writeFileSync(SERIAL_XML, emptyJUnitXml(), 'utf8');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const requestedConcurrency = Number(process.env.TEST_CONCURRENCY ?? '');
|
|
111
|
+
const parallelConcurrency = Number.isFinite(requestedConcurrency) && requestedConcurrency > 0 ? requestedConcurrency : 8;
|
|
112
|
+
|
|
113
|
+
if (parallelTests.length > 0) {
|
|
114
|
+
const parallelResult = runNodeTest({
|
|
115
|
+
concurrency: parallelConcurrency,
|
|
116
|
+
destination: PARALLEL_XML,
|
|
117
|
+
tests: parallelTests,
|
|
118
|
+
});
|
|
119
|
+
ensureJUnitFile(PARALLEL_XML);
|
|
120
|
+
parallelStatus = getResultStatus(parallelResult);
|
|
121
|
+
} else {
|
|
122
|
+
writeFileSync(PARALLEL_XML, emptyJUnitXml(), 'utf8');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
combineJunit(SERIAL_XML, PARALLEL_XML, COMBINED_XML);
|
|
126
|
+
|
|
127
|
+
if (serialStatus !== 0 || parallelStatus !== 0) {
|
|
128
|
+
process.exit(serialStatus !== 0 ? serialStatus : parallelStatus);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
main();
|