feat(functions): Add emulator support for integration tests - #1897
feat(functions): Add emulator support for integration tests#1897inlined wants to merge 5 commits into
Conversation
- Modernize package.json dependencies in integration tests for functions, app_check, and messaging. - Resolve vulnerability in uuid via dependency overrides. - Introduce start.js to spin up local functions emulator dynamically on port 5005. - Strip http:// and https:// prefixes from emulator host strings for iOS to prevent crashes on native iOS client initialization. - Parse FUNCTIONS_EMULATOR_HOST environment variable and --functions_emulator_host flag, and connect functions clients in integration tests to the emulator.
Wiz Scan Summary
To detect these findings earlier in the dev lifecycle, try the Wiz Code extension for VS Code, JetBrains, or Visual Studio. |
There was a problem hiding this comment.
Code Review
This pull request adds support for running integration tests against the Firebase Functions emulator by introducing a startup script (start.js), updating dependencies, and implementing emulator configuration logic across multiple test suites. Feedback focuses on improving the robustness of the new code: ensuring the temporary directory in start.js is cleaned up on failure or exit, verifying the emulator child process status in waitForPort to prevent false positives when a port is already in use, and validating that the parsed port in functions_ios.mm is within the valid range (1-65535).
| async function main() { | ||
| const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'firebase-functions-')); | ||
| console.log(`Creating functions in ${tempDir}`); | ||
|
|
||
| // Create functions subdirectory | ||
| fs.mkdirSync(path.join(tempDir, 'functions'), { recursive: true }); | ||
|
|
||
| // Copy files | ||
| fs.copyFileSync(path.join(scriptDir, 'functions', 'index.js'), path.join(tempDir, 'functions', 'index.js')); | ||
| fs.copyFileSync(path.join(scriptDir, 'functions', 'package.json'), path.join(tempDir, 'functions', 'package.json')); | ||
| fs.copyFileSync(path.join(scriptDir, 'firebase.json'), path.join(tempDir, 'firebase.json')); | ||
|
|
||
| // npm install in functions directory | ||
| console.log('Installing dependencies...'); | ||
| await runCommand(os.platform() === 'win32' ? 'npm.cmd' : 'npm', ['install'], { cwd: path.join(tempDir, 'functions') }); | ||
|
|
||
| // Start the server | ||
| console.log('Starting the emulator...'); | ||
| const logFile = path.join(tempDir, 'firebase-emulator.log'); | ||
| const out = fs.openSync(logFile, 'a'); | ||
| const err = fs.openSync(logFile, 'a'); | ||
|
|
||
| const child = spawn( | ||
| os.platform() === 'win32' ? 'npx.cmd' : 'npx', | ||
| ['firebase', 'emulators:start', '--only', 'functions', '--project', 'functions-integration-test'], | ||
| { | ||
| cwd: tempDir, | ||
| detached: isSynchronous, | ||
| stdio: isSynchronous ? ['ignore', out, err] : 'inherit' | ||
| } | ||
| ); | ||
|
|
||
| if (isSynchronous) { | ||
| child.unref(); | ||
| } | ||
|
|
||
| // Wait for the emulator to be ready | ||
| console.log('Waiting for emulator to start...'); | ||
| const ready = await waitForPort(5005, '127.0.0.1', 30); | ||
| if (!ready) { | ||
| console.error('Emulator failed to start within 30 seconds.'); | ||
| if (fs.existsSync(logFile)) { | ||
| console.error(fs.readFileSync(logFile, 'utf8')); | ||
| } | ||
| process.exit(1); | ||
| } | ||
| console.log('Emulator is ready!'); | ||
|
|
||
| if (!isSynchronous) { | ||
| console.log(`Functions emulator now running in ${tempDir}.`); | ||
| const rl = readline.createInterface({ | ||
| input: process.stdin, | ||
| output: process.stdout | ||
| }); | ||
|
|
||
| rl.question('*** Press Enter to stop the server. ***\n', () => { | ||
| rl.close(); | ||
| child.kill(); | ||
| process.exit(0); | ||
| }); | ||
|
|
||
| // Handle termination signals | ||
| const cleanup = () => { | ||
| child.kill(); | ||
| process.exit(0); | ||
| }; | ||
| process.on('SIGINT', cleanup); | ||
| process.on('SIGTERM', cleanup); | ||
| } else { | ||
| // Exit parent process, leaving the detached child running | ||
| process.exit(0); | ||
| } | ||
| } |
There was a problem hiding this comment.
The temporary directory tempDir created by fs.mkdtempSync is never cleaned up if the script fails during setup (e.g., if npm install fails or the emulator fails to start), or when the script exits in interactive mode. Since this directory contains a copy of the functions and a full node_modules folder, this can quickly consume significant disk space on CI servers and developer machines. Wrapping the main execution in a try...catch block and cleaning up tempDir on failure or exit ensures proper resource management.
async function main() {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'firebase-functions-'));
console.log(`Creating functions in ${tempDir}`);
try {
// Create functions subdirectory
fs.mkdirSync(path.join(tempDir, 'functions'), { recursive: true });
// Copy files
fs.copyFileSync(path.join(scriptDir, 'functions', 'index.js'), path.join(tempDir, 'functions', 'index.js'));
fs.copyFileSync(path.join(scriptDir, 'functions', 'package.json'), path.join(tempDir, 'functions', 'package.json'));
fs.copyFileSync(path.join(scriptDir, 'firebase.json'), path.join(tempDir, 'firebase.json'));
// npm install in functions directory
console.log('Installing dependencies...');
await runCommand(os.platform() === 'win32' ? 'npm.cmd' : 'npm', ['install'], { cwd: path.join(tempDir, 'functions') });
// Start the server
console.log('Starting the emulator...');
const logFile = path.join(tempDir, 'firebase-emulator.log');
const out = fs.openSync(logFile, 'a');
const err = fs.openSync(logFile, 'a');
const child = spawn(
os.platform() === 'win32' ? 'npx.cmd' : 'npx',
['firebase', 'emulators:start', '--only', 'functions', '--project', 'functions-integration-test'],
{
cwd: tempDir,
detached: isSynchronous,
stdio: isSynchronous ? ['ignore', out, err] : 'inherit'
}
);
if (isSynchronous) {
child.unref();
}
// Wait for the emulator to be ready
console.log('Waiting for emulator to start...');
const ready = await waitForPort(5005, '127.0.0.1', 30, child);
if (!ready) {
console.error('Emulator failed to start within 30 seconds.');
if (fs.existsSync(logFile)) {
console.error(fs.readFileSync(logFile, 'utf8'));
}
if (!isSynchronous) {
child.kill();
}
throw new Error('Emulator failed to start');
}
console.log('Emulator is ready!');
if (!isSynchronous) {
console.log(`Functions emulator now running in ${tempDir}.`);
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.question('*** Press Enter to stop the server. ***\n', () => {
rl.close();
child.kill();
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch (err) {}
process.exit(0);
});
// Handle termination signals
const cleanup = () => {
child.kill();
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch (err) {}
process.exit(0);
};
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
} else {
// Exit parent process, leaving the detached child running
process.exit(0);
}
} catch (err) {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch (cleanupErr) {
console.error(`Failed to clean up temp directory ${tempDir}:`, cleanupErr);
}
throw err;
}
}| function waitForPort(port, host, retries) { | ||
| const check = () => new Promise((resolve, reject) => { | ||
| const s = new net.Socket(); | ||
| s.setTimeout(1000); | ||
| s.on('connect', () => { s.destroy(); resolve(true); }); | ||
| s.on('error', () => resolve(false)); | ||
| s.on('timeout', () => { s.destroy(); resolve(false); }); | ||
| s.connect(port, host); | ||
| }); | ||
|
|
||
| return new Promise(async (resolve) => { | ||
| for (let i = 0; i < retries; i++) { | ||
| if (await check()) { | ||
| resolve(true); | ||
| return; | ||
| } | ||
| await new Promise(r => setTimeout(r, 1000)); | ||
| } | ||
| resolve(false); | ||
| }); | ||
| } |
There was a problem hiding this comment.
If the emulator port (5005) is already in use by another process, waitForPort will immediately succeed because it can establish a socket connection, even though the emulator child process failed to start and exited. Passing the child process to waitForPort and checking child.exitCode !== null allows the script to fail fast and report the failure correctly instead of falsely reporting that the emulator is ready.
function waitForPort(port, host, retries, child) {
const check = () => new Promise((resolve, reject) => {
const s = new net.Socket();
s.setTimeout(1000);
s.on('connect', () => { s.destroy(); resolve(true); });
s.on('error', () => resolve(false));
s.on('timeout', () => { s.destroy(); resolve(false); });
s.connect(port, host);
});
return new Promise(async (resolve) => {
for (let i = 0; i < retries; i++) {
if (child && child.exitCode !== null) {
resolve(false);
return;
}
if (await check()) {
if (child && child.exitCode !== null) {
resolve(false);
return;
}
resolve(true);
return;
}
await new Promise(r => setTimeout(r, 1000));
}
resolve(false);
});
}| std::string port_str = origin_str.substr(pos+1, std::string::npos); | ||
| int port = atoi(port_str.c_str()); | ||
| [impl_.get()->get() useEmulatorWithHost:@(host.c_str()) port:port]; |
There was a problem hiding this comment.
The port parsed from origin_str is converted using atoi, which returns 0 if the port string is invalid or non-numeric (e.g., localhost:abc). Passing 0 or an out-of-range port to the native SDK can lead to unexpected behavior. Adding validation to ensure the port is within the valid range (1-65535) improves robustness.
std::string port_str = origin_str.substr(pos+1, std::string::npos);
int port = atoi(port_str.c_str());
if (port <= 0 || port > 65535) {
LogError("Functions::UseFunctionsEmulator: Invalid port specified");
return;
}
[impl_.get()->get() useEmulatorWithHost:@(host.c_str()) port:port];
This PR performs a security audit and modernizes functions integration test configuration. It also integrates Cloud Functions emulator support into the C++ SDK client integration tests.