Skip to content

Commit 55ba8a6

Browse files
committed
refactor(integration_test): factor out into smaller modules
1 parent 7d7f856 commit 55ba8a6

30 files changed

+1449
-816
lines changed

integration_test/deployment-utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pRetry from "p-retry";
22
import pLimit from "p-limit";
3-
import { logger } from "./src/logger.js";
3+
import { logger } from "./src/utils/logger.js";
44

55
interface FirebaseClient {
66
functions: {

integration_test/run.backup.ts

Lines changed: 376 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
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

Comments
 (0)