|
| 1 | +import fs from "fs"; |
| 2 | +import yaml from "js-yaml"; |
| 3 | +import { spawn } from "child_process"; |
| 4 | +import portfinder from "portfinder"; |
| 5 | +import client from "firebase-tools"; |
| 6 | +import { getRuntimeDelegate } from "firebase-tools/lib/deploy/functions/runtimes/index.js"; |
| 7 | +import { detectFromPort } from "firebase-tools/lib/deploy/functions/runtimes/discovery/index.js"; |
| 8 | +import setup from "./setup.js"; |
| 9 | +import * as dotenv from "dotenv"; |
| 10 | +import { deployFunctionsWithRetry, postCleanup } from "./deployment-utils.js"; |
| 11 | +import { logger } from "./src/utils/logger.js"; |
| 12 | + |
| 13 | +dotenv.config(); |
| 14 | + |
| 15 | +let { |
| 16 | + DEBUG, |
| 17 | + NODE_VERSION = "18", |
| 18 | + FIREBASE_ADMIN, |
| 19 | + PROJECT_ID, |
| 20 | + DATABASE_URL, |
| 21 | + STORAGE_BUCKET, |
| 22 | + FIREBASE_APP_ID, |
| 23 | + FIREBASE_MEASUREMENT_ID, |
| 24 | + FIREBASE_AUTH_DOMAIN, |
| 25 | + FIREBASE_API_KEY, |
| 26 | + // GOOGLE_ANALYTICS_API_SECRET, |
| 27 | + TEST_RUNTIME, |
| 28 | + REGION = "us-central1", |
| 29 | + STORAGE_REGION = "us-central1", |
| 30 | +} = process.env; |
| 31 | +const TEST_RUN_ID = `t${Date.now()}`; |
| 32 | + |
| 33 | +if ( |
| 34 | + !PROJECT_ID || |
| 35 | + !DATABASE_URL || |
| 36 | + !STORAGE_BUCKET || |
| 37 | + !FIREBASE_APP_ID || |
| 38 | + !FIREBASE_MEASUREMENT_ID || |
| 39 | + !FIREBASE_AUTH_DOMAIN || |
| 40 | + !FIREBASE_API_KEY || |
| 41 | + // !GOOGLE_ANALYTICS_API_SECRET || |
| 42 | + !TEST_RUNTIME |
| 43 | +) { |
| 44 | + logger.error("Required environment variables are not set. Exiting..."); |
| 45 | + process.exit(1); |
| 46 | +} |
| 47 | + |
| 48 | +if (!["node", "python"].includes(TEST_RUNTIME)) { |
| 49 | + logger.error("Invalid TEST_RUNTIME. Must be either 'node' or 'python'. Exiting..."); |
| 50 | + process.exit(1); |
| 51 | +} |
| 52 | + |
| 53 | +// TypeScript type guard to ensure TEST_RUNTIME is the correct type |
| 54 | +const validRuntimes = ["node", "python"] as const; |
| 55 | +type ValidRuntime = (typeof validRuntimes)[number]; |
| 56 | +const runtime: ValidRuntime = TEST_RUNTIME as ValidRuntime; |
| 57 | + |
| 58 | +if (!FIREBASE_ADMIN && runtime === "node") { |
| 59 | + FIREBASE_ADMIN = "^12.0.0"; |
| 60 | +} else if (!FIREBASE_ADMIN && runtime === "python") { |
| 61 | + FIREBASE_ADMIN = "6.5.0"; |
| 62 | +} else if (!FIREBASE_ADMIN) { |
| 63 | + throw new Error("FIREBASE_ADMIN is not set"); |
| 64 | +} |
| 65 | + |
| 66 | +setup(runtime, TEST_RUN_ID, NODE_VERSION, FIREBASE_ADMIN); |
| 67 | + |
| 68 | +// Configure Firebase client with project ID |
| 69 | +logger.info("Configuring Firebase client with project ID:", PROJECT_ID); |
| 70 | +const firebaseClient = client; |
| 71 | + |
| 72 | +const config = { |
| 73 | + projectId: PROJECT_ID, |
| 74 | + projectDir: process.cwd(), |
| 75 | + sourceDir: `${process.cwd()}/functions`, |
| 76 | + runtime: runtime === "node" ? "nodejs18" : "python311", |
| 77 | +}; |
| 78 | + |
| 79 | +logger.debug("Firebase config created: "); |
| 80 | +logger.debug(JSON.stringify(config, null, 2)); |
| 81 | + |
| 82 | +const firebaseConfig = { |
| 83 | + databaseURL: DATABASE_URL, |
| 84 | + projectId: PROJECT_ID, |
| 85 | + storageBucket: STORAGE_BUCKET, |
| 86 | +}; |
| 87 | + |
| 88 | +const env = { |
| 89 | + DEBUG, |
| 90 | + FIRESTORE_PREFER_REST: "true", |
| 91 | + GCLOUD_PROJECT: config.projectId, |
| 92 | + FIREBASE_CONFIG: JSON.stringify(firebaseConfig), |
| 93 | + REGION, |
| 94 | + STORAGE_REGION, |
| 95 | +}; |
| 96 | + |
| 97 | +interface EndpointConfig { |
| 98 | + project?: string; |
| 99 | + runtime?: string; |
| 100 | + [key: string]: unknown; |
| 101 | +} |
| 102 | + |
| 103 | +interface ModifiedYaml { |
| 104 | + endpoints: Record<string, EndpointConfig>; |
| 105 | + specVersion: string; |
| 106 | +} |
| 107 | + |
| 108 | +let modifiedYaml: ModifiedYaml | undefined; |
| 109 | + |
| 110 | +function generateUniqueHash(originalName: string): string { |
| 111 | + // Function name can only contain letters, numbers and hyphens and be less than 100 chars. |
| 112 | + const modifiedName = `${TEST_RUN_ID}-${originalName}`; |
| 113 | + if (modifiedName.length > 100) { |
| 114 | + throw new Error( |
| 115 | + `Function name is too long. Original=${originalName}, Modified=${modifiedName}` |
| 116 | + ); |
| 117 | + } |
| 118 | + return modifiedName; |
| 119 | +} |
| 120 | + |
| 121 | +/** |
| 122 | + * Discovers endpoints and modifies functions.yaml file. |
| 123 | + * @returns A promise that resolves with a function to kill the server. |
| 124 | + */ |
| 125 | +async function discoverAndModifyEndpoints() { |
| 126 | + logger.info("Discovering endpoints..."); |
| 127 | + try { |
| 128 | + const port = await portfinder.getPortPromise({ port: 9000 }); |
| 129 | + const delegate = await getRuntimeDelegate(config); |
| 130 | + const killServer = await delegate.serveAdmin(port.toString(), {}, env); |
| 131 | + |
| 132 | + logger.info("Started on port", port); |
| 133 | + const originalYaml = (await detectFromPort( |
| 134 | + port, |
| 135 | + config.projectId, |
| 136 | + config.runtime, |
| 137 | + 10000 |
| 138 | + )) as ModifiedYaml; |
| 139 | + |
| 140 | + modifiedYaml = { |
| 141 | + ...originalYaml, |
| 142 | + endpoints: Object.fromEntries( |
| 143 | + Object.entries(originalYaml.endpoints).map(([key, value]) => { |
| 144 | + const modifiedKey = generateUniqueHash(key); |
| 145 | + const modifiedValue: EndpointConfig = { ...value }; |
| 146 | + delete modifiedValue.project; |
| 147 | + delete modifiedValue.runtime; |
| 148 | + return [modifiedKey, modifiedValue]; |
| 149 | + }) |
| 150 | + ), |
| 151 | + specVersion: "v1alpha1", |
| 152 | + }; |
| 153 | + |
| 154 | + writeFunctionsYaml("./functions/functions.yaml", modifiedYaml); |
| 155 | + |
| 156 | + return killServer; |
| 157 | + } catch (err) { |
| 158 | + logger.error("Error discovering endpoints. Exiting.", err); |
| 159 | + process.exit(1); |
| 160 | + } |
| 161 | +} |
| 162 | + |
| 163 | +function writeFunctionsYaml(filePath: string, data: any): void { |
| 164 | + try { |
| 165 | + fs.writeFileSync(filePath, yaml.dump(data)); |
| 166 | + } catch (err) { |
| 167 | + logger.error("Error writing functions.yaml. Exiting.", err); |
| 168 | + process.exit(1); |
| 169 | + } |
| 170 | +} |
| 171 | + |
| 172 | +async function deployModifiedFunctions(): Promise<void> { |
| 173 | + logger.deployment(`Deploying functions with id: ${TEST_RUN_ID}`); |
| 174 | + try { |
| 175 | + // Get the function names that will be deployed |
| 176 | + const functionNames = modifiedYaml ? Object.keys(modifiedYaml.endpoints) : []; |
| 177 | + |
| 178 | + logger.deployment("Functions to deploy:", functionNames); |
| 179 | + logger.deployment(`Total functions to deploy: ${functionNames.length}`); |
| 180 | + |
| 181 | + // Deploy with rate limiting and retry logic |
| 182 | + await deployFunctionsWithRetry(firebaseClient, functionNames); |
| 183 | + |
| 184 | + logger.success("Functions have been deployed successfully."); |
| 185 | + logger.info("You can view your deployed functions in the Firebase Console:"); |
| 186 | + logger.info(` https://console.firebase.google.com/project/${PROJECT_ID}/functions`); |
| 187 | + } catch (err) { |
| 188 | + logger.error("Error deploying functions. Exiting.", err); |
| 189 | + throw err; |
| 190 | + } |
| 191 | +} |
| 192 | + |
| 193 | +function cleanFiles(): void { |
| 194 | + logger.cleanup("Cleaning files..."); |
| 195 | + const functionsDir = "functions"; |
| 196 | + process.chdir(functionsDir); // go to functions |
| 197 | + try { |
| 198 | + const files = fs.readdirSync("."); |
| 199 | + const deletedFiles: string[] = []; |
| 200 | + |
| 201 | + files.forEach((file) => { |
| 202 | + // For Node |
| 203 | + if (file.match(`firebase-functions-${TEST_RUN_ID}.tgz`)) { |
| 204 | + fs.rmSync(file); |
| 205 | + deletedFiles.push(file); |
| 206 | + } |
| 207 | + // For Python |
| 208 | + if (file.match(`firebase_functions.tar.gz`)) { |
| 209 | + fs.rmSync(file); |
| 210 | + deletedFiles.push(file); |
| 211 | + } |
| 212 | + if (file.match("package.json")) { |
| 213 | + fs.rmSync(file); |
| 214 | + deletedFiles.push(file); |
| 215 | + } |
| 216 | + if (file.match("requirements.txt")) { |
| 217 | + fs.rmSync(file); |
| 218 | + deletedFiles.push(file); |
| 219 | + } |
| 220 | + if (file.match("firebase-debug.log")) { |
| 221 | + fs.rmSync(file); |
| 222 | + deletedFiles.push(file); |
| 223 | + } |
| 224 | + if (file.match("functions.yaml")) { |
| 225 | + fs.rmSync(file); |
| 226 | + deletedFiles.push(file); |
| 227 | + } |
| 228 | + }); |
| 229 | + |
| 230 | + // Check and delete directories |
| 231 | + if (fs.existsSync("lib")) { |
| 232 | + fs.rmSync("lib", { recursive: true, force: true }); |
| 233 | + deletedFiles.push("lib/ (directory)"); |
| 234 | + } |
| 235 | + if (fs.existsSync("venv")) { |
| 236 | + fs.rmSync("venv", { recursive: true, force: true }); |
| 237 | + deletedFiles.push("venv/ (directory)"); |
| 238 | + } |
| 239 | + |
| 240 | + if (deletedFiles.length > 0) { |
| 241 | + logger.cleanup(`Deleted ${deletedFiles.length} files/directories:`); |
| 242 | + deletedFiles.forEach((file, index) => { |
| 243 | + logger.debug(` ${index + 1}. ${file}`); |
| 244 | + }); |
| 245 | + } else { |
| 246 | + logger.info("No files to clean up"); |
| 247 | + } |
| 248 | + } catch (error) { |
| 249 | + logger.error("Error occurred while cleaning files:", error); |
| 250 | + } |
| 251 | + |
| 252 | + process.chdir("../"); // go back to integration_test |
| 253 | +} |
| 254 | + |
| 255 | +const spawnAsync = (command: string, args: string[], options: any): Promise<string> => { |
| 256 | + return new Promise((resolve, reject) => { |
| 257 | + const child = spawn(command, args, options); |
| 258 | + |
| 259 | + let output = ""; |
| 260 | + let errorOutput = ""; |
| 261 | + |
| 262 | + if (child.stdout) { |
| 263 | + child.stdout.on("data", (data) => { |
| 264 | + output += data.toString(); |
| 265 | + }); |
| 266 | + } |
| 267 | + |
| 268 | + if (child.stderr) { |
| 269 | + child.stderr.on("data", (data) => { |
| 270 | + errorOutput += data.toString(); |
| 271 | + }); |
| 272 | + } |
| 273 | + |
| 274 | + child.on("error", reject); |
| 275 | + |
| 276 | + child.on("close", (code) => { |
| 277 | + if (code === 0) { |
| 278 | + resolve(output); |
| 279 | + } else { |
| 280 | + const errorMessage = `Command failed with exit code ${code}`; |
| 281 | + const fullError = errorOutput ? `${errorMessage}\n\nSTDERR:\n${errorOutput}` : errorMessage; |
| 282 | + reject(new Error(fullError)); |
| 283 | + } |
| 284 | + }); |
| 285 | + |
| 286 | + // Add timeout to prevent hanging |
| 287 | + const timeout = setTimeout(() => { |
| 288 | + child.kill(); |
| 289 | + reject(new Error(`Command timed out after 5 minutes: ${command} ${args.join(" ")}`)); |
| 290 | + }, 5 * 60 * 1000); // 5 minutes |
| 291 | + |
| 292 | + child.on("close", () => { |
| 293 | + clearTimeout(timeout); |
| 294 | + }); |
| 295 | + }); |
| 296 | +}; |
| 297 | + |
| 298 | +async function runTests(): Promise<void> { |
| 299 | + const humanReadableRuntime = TEST_RUNTIME === "node" ? "Node.js" : "Python"; |
| 300 | + try { |
| 301 | + logger.info(`Starting ${humanReadableRuntime} Tests...`); |
| 302 | + logger.info("Running all integration tests"); |
| 303 | + |
| 304 | + // Run all tests |
| 305 | + const output = await spawnAsync("npx", ["jest", "--verbose"], { |
| 306 | + env: { |
| 307 | + ...process.env, |
| 308 | + TEST_RUN_ID, |
| 309 | + }, |
| 310 | + }); |
| 311 | + |
| 312 | + logger.info("Test output received:"); |
| 313 | + logger.debug(output); |
| 314 | + |
| 315 | + // Check if tests passed |
| 316 | + if (output.includes("PASS") && !output.includes("FAIL")) { |
| 317 | + logger.success("All tests completed successfully!"); |
| 318 | + logger.success("All function triggers are working correctly."); |
| 319 | + } else { |
| 320 | + logger.warning("Some tests may have failed. Check the output above."); |
| 321 | + } |
| 322 | + |
| 323 | + logger.info(`${humanReadableRuntime} Tests Completed.`); |
| 324 | + } catch (error) { |
| 325 | + logger.error("Error during testing:", error); |
| 326 | + throw error; |
| 327 | + } |
| 328 | +} |
| 329 | + |
| 330 | +async function handleCleanUp(): Promise<void> { |
| 331 | + logger.cleanup("Cleaning up..."); |
| 332 | + try { |
| 333 | + // Use our new post-cleanup utility with rate limiting |
| 334 | + await postCleanup(firebaseClient, TEST_RUN_ID); |
| 335 | + } catch (err) { |
| 336 | + logger.error("Error during post-cleanup:", err); |
| 337 | + // Don't throw here to ensure files are still cleaned |
| 338 | + } |
| 339 | + cleanFiles(); |
| 340 | +} |
| 341 | + |
| 342 | +async function gracefulShutdown(): Promise<void> { |
| 343 | + logger.info("SIGINT received..."); |
| 344 | + await handleCleanUp(); |
| 345 | + process.exit(1); |
| 346 | +} |
| 347 | + |
| 348 | +async function runIntegrationTests(): Promise<void> { |
| 349 | + process.on("SIGINT", gracefulShutdown); |
| 350 | + |
| 351 | + try { |
| 352 | + // Skip pre-cleanup for now to test if the main flow works |
| 353 | + logger.info("Skipping pre-cleanup for testing..."); |
| 354 | + |
| 355 | + const killServer = await discoverAndModifyEndpoints(); |
| 356 | + await deployModifiedFunctions(); |
| 357 | + await killServer(); |
| 358 | + await runTests(); |
| 359 | + } catch (err) { |
| 360 | + logger.error("Error occurred during integration tests:", err); |
| 361 | + // Re-throw the original error instead of wrapping it |
| 362 | + throw err; |
| 363 | + } finally { |
| 364 | + await handleCleanUp(); |
| 365 | + } |
| 366 | +} |
| 367 | + |
| 368 | +runIntegrationTests() |
| 369 | + .then(() => { |
| 370 | + logger.success("Integration tests completed"); |
| 371 | + process.exit(0); |
| 372 | + }) |
| 373 | + .catch((error) => { |
| 374 | + logger.error("An error occurred during integration tests", error); |
| 375 | + process.exit(1); |
| 376 | + }); |
0 commit comments