|
1 | 1 | import * as subprocess from "child_process";
|
2 | 2 | import * as path from "path";
|
3 | 3 | import { promisify } from "util";
|
| 4 | +import * as fs from "fs/promises"; |
| 5 | +import * as os from "os"; |
4 | 6 |
|
5 | 7 | import { expect } from "chai";
|
6 | 8 | import * as yaml from "js-yaml";
|
@@ -105,7 +107,13 @@ const BASE_STACK = {
|
105 | 107 | interface Testcase {
|
106 | 108 | name: string;
|
107 | 109 | modulePath: string;
|
108 |
| - expected: Record<string, any>; |
| 110 | + expected: Record<string, unknown>; |
| 111 | +} |
| 112 | + |
| 113 | +interface DiscoveryResult { |
| 114 | + success: boolean; |
| 115 | + manifest?: Record<string, unknown>; |
| 116 | + error?: string; |
109 | 117 | }
|
110 | 118 |
|
111 | 119 | async function retryUntil(
|
@@ -134,102 +142,134 @@ async function retryUntil(
|
134 | 142 | await Promise.race([retry, timedOut]);
|
135 | 143 | }
|
136 | 144 |
|
137 |
| -async function startBin( |
138 |
| - tc: Testcase, |
139 |
| - debug?: boolean |
140 |
| -): Promise<{ port: number; cleanup: () => Promise<void> }> { |
| 145 | +async function runHttpDiscovery(modulePath: string): Promise<DiscoveryResult> { |
141 | 146 | const getPort = promisify(portfinder.getPort) as () => Promise<number>;
|
142 | 147 | const port = await getPort();
|
143 | 148 |
|
144 | 149 | const proc = subprocess.spawn("npx", ["firebase-functions"], {
|
145 |
| - cwd: path.resolve(tc.modulePath), |
| 150 | + cwd: path.resolve(modulePath), |
146 | 151 | env: {
|
147 | 152 | PATH: process.env.PATH,
|
148 |
| - GLCOUD_PROJECT: "test-project", |
| 153 | + GCLOUD_PROJECT: "test-project", |
149 | 154 | PORT: port.toString(),
|
150 | 155 | FUNCTIONS_CONTROL_API: "true",
|
151 | 156 | },
|
152 | 157 | });
|
153 |
| - if (!proc) { |
154 |
| - throw new Error("Failed to start firebase functions"); |
155 |
| - } |
156 |
| - proc.stdout?.on("data", (chunk: Buffer) => { |
157 |
| - console.log(chunk.toString("utf8")); |
158 |
| - }); |
159 |
| - proc.stderr?.on("data", (chunk: Buffer) => { |
160 |
| - console.log(chunk.toString("utf8")); |
161 |
| - }); |
162 | 158 |
|
163 |
| - await retryUntil(async () => { |
164 |
| - try { |
165 |
| - await fetch(`http://localhost:${port}/__/functions.yaml`); |
166 |
| - } catch (e) { |
167 |
| - if (e?.code === "ECONNREFUSED") { |
168 |
| - return false; |
| 159 | + try { |
| 160 | + // Wait for server to be ready |
| 161 | + await retryUntil(async () => { |
| 162 | + try { |
| 163 | + await fetch(`http://localhost:${port}/__/functions.yaml`); |
| 164 | + return true; |
| 165 | + } catch (e: unknown) { |
| 166 | + const error = e as { code?: string }; |
| 167 | + if (error.code === "ECONNREFUSED") { |
| 168 | + // This is an expected error during server startup, so we should retry. |
| 169 | + return false; |
| 170 | + } |
| 171 | + // Any other error is unexpected and should fail the test immediately. |
| 172 | + throw e; |
169 | 173 | }
|
170 |
| - throw e; |
| 174 | + }, TIMEOUT_L); |
| 175 | + |
| 176 | + const res = await fetch(`http://localhost:${port}/__/functions.yaml`); |
| 177 | + const body = await res.text(); |
| 178 | + |
| 179 | + if (res.status === 200) { |
| 180 | + const manifest = yaml.load(body) as Record<string, unknown>; |
| 181 | + return { success: true, manifest }; |
| 182 | + } else { |
| 183 | + return { success: false, error: body }; |
171 | 184 | }
|
172 |
| - return true; |
173 |
| - }, TIMEOUT_L); |
| 185 | + } finally { |
| 186 | + if (proc.pid) { |
| 187 | + proc.kill(9); |
| 188 | + await new Promise<void>((resolve) => proc.on("exit", resolve)); |
| 189 | + } |
| 190 | + } |
| 191 | +} |
174 | 192 |
|
175 |
| - if (debug) { |
176 |
| - proc.stdout?.on("data", (data: unknown) => { |
177 |
| - console.log(`[${tc.name} stdout] ${data}`); |
| 193 | +async function runFileDiscovery(modulePath: string): Promise<DiscoveryResult> { |
| 194 | + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "firebase-functions-test-")); |
| 195 | + const outputPath = path.join(tempDir, "manifest.json"); |
| 196 | + |
| 197 | + return new Promise((resolve, reject) => { |
| 198 | + const proc = subprocess.spawn("npx", ["firebase-functions"], { |
| 199 | + cwd: path.resolve(modulePath), |
| 200 | + env: { |
| 201 | + PATH: process.env.PATH, |
| 202 | + GCLOUD_PROJECT: "test-project", |
| 203 | + FUNCTIONS_MANIFEST_OUTPUT_PATH: outputPath, |
| 204 | + }, |
178 | 205 | });
|
179 | 206 |
|
180 |
| - proc.stderr?.on("data", (data: unknown) => { |
181 |
| - console.log(`[${tc.name} stderr] ${data}`); |
| 207 | + let stderr = ""; |
| 208 | + |
| 209 | + proc.stderr?.on("data", (chunk: Buffer) => { |
| 210 | + stderr += chunk.toString("utf8"); |
182 | 211 | });
|
183 |
| - } |
184 | 212 |
|
185 |
| - return { |
186 |
| - port, |
187 |
| - cleanup: async () => { |
188 |
| - process.kill(proc.pid, 9); |
189 |
| - await retryUntil(async () => { |
| 213 | + const timeoutId = setTimeout(async () => { |
| 214 | + if (proc.pid) { |
| 215 | + proc.kill(9); |
| 216 | + await new Promise<void>((resolve) => proc.on("exit", resolve)); |
| 217 | + } |
| 218 | + resolve({ success: false, error: `File discovery timed out after ${TIMEOUT_M}ms` }); |
| 219 | + }, TIMEOUT_M); |
| 220 | + |
| 221 | + proc.on("close", async (code) => { |
| 222 | + clearTimeout(timeoutId); |
| 223 | + |
| 224 | + if (code === 0) { |
190 | 225 | try {
|
191 |
| - process.kill(proc.pid, 0); |
192 |
| - } catch { |
193 |
| - // process.kill w/ signal 0 will throw an error if the pid no longer exists. |
194 |
| - return Promise.resolve(true); |
| 226 | + const manifestJson = await fs.readFile(outputPath, "utf8"); |
| 227 | + const manifest = JSON.parse(manifestJson) as Record<string, unknown>; |
| 228 | + await fs.rm(tempDir, { recursive: true }).catch(() => { |
| 229 | + // Ignore errors |
| 230 | + }); |
| 231 | + resolve({ success: true, manifest }); |
| 232 | + } catch (e) { |
| 233 | + resolve({ success: false, error: `Failed to read manifest file: ${e}` }); |
195 | 234 | }
|
196 |
| - return Promise.resolve(false); |
197 |
| - }, TIMEOUT_L); |
198 |
| - }, |
199 |
| - }; |
| 235 | + } else { |
| 236 | + const errorLines = stderr.split("\n").filter((line) => line.trim()); |
| 237 | + const errorMessage = errorLines.join(" ") || "No error message found"; |
| 238 | + resolve({ success: false, error: errorMessage }); |
| 239 | + } |
| 240 | + }); |
| 241 | + |
| 242 | + proc.on("error", (err) => { |
| 243 | + clearTimeout(timeoutId); |
| 244 | + // Clean up temp directory on error |
| 245 | + fs.rm(tempDir, { recursive: true }).catch(() => { |
| 246 | + // Ignore errors |
| 247 | + }); |
| 248 | + reject(err); |
| 249 | + }); |
| 250 | + }); |
200 | 251 | }
|
201 | 252 |
|
202 | 253 | describe("functions.yaml", function () {
|
203 | 254 | // eslint-disable-next-line @typescript-eslint/no-invalid-this
|
204 | 255 | this.timeout(TIMEOUT_XL);
|
205 | 256 |
|
206 |
| - function runTests(tc: Testcase) { |
207 |
| - let port: number; |
208 |
| - let cleanup: () => Promise<void>; |
209 |
| - |
210 |
| - before(async () => { |
211 |
| - const r = await startBin(tc); |
212 |
| - port = r.port; |
213 |
| - cleanup = r.cleanup; |
214 |
| - }); |
| 257 | + const discoveryMethods = [ |
| 258 | + { name: "http", fn: runHttpDiscovery }, |
| 259 | + { name: "file", fn: runFileDiscovery }, |
| 260 | + ]; |
215 | 261 |
|
216 |
| - after(async () => { |
217 |
| - await cleanup?.(); |
218 |
| - }); |
219 |
| - |
220 |
| - it("functions.yaml returns expected Manifest", async function () { |
| 262 | + function runDiscoveryTests( |
| 263 | + tc: Testcase, |
| 264 | + discoveryFn: (path: string) => Promise<DiscoveryResult> |
| 265 | + ) { |
| 266 | + it("returns expected manifest", async function () { |
221 | 267 | // eslint-disable-next-line @typescript-eslint/no-invalid-this
|
222 | 268 | this.timeout(TIMEOUT_M);
|
223 | 269 |
|
224 |
| - const res = await fetch(`http://localhost:${port}/__/functions.yaml`); |
225 |
| - const text = await res.text(); |
226 |
| - let parsed: any; |
227 |
| - try { |
228 |
| - parsed = yaml.load(text); |
229 |
| - } catch (err) { |
230 |
| - throw new Error(`Failed to parse functions.yaml: ${err}`); |
231 |
| - } |
232 |
| - expect(parsed).to.be.deep.equal(tc.expected); |
| 270 | + const result = await discoveryFn(tc.modulePath); |
| 271 | + expect(result.success).to.be.true; |
| 272 | + expect(result.manifest).to.deep.equal(tc.expected); |
233 | 273 | });
|
234 | 274 | }
|
235 | 275 |
|
@@ -320,7 +360,11 @@ describe("functions.yaml", function () {
|
320 | 360 |
|
321 | 361 | for (const tc of testcases) {
|
322 | 362 | describe(tc.name, () => {
|
323 |
| - runTests(tc); |
| 363 | + for (const discovery of discoveryMethods) { |
| 364 | + describe(`${discovery.name} discovery`, () => { |
| 365 | + runDiscoveryTests(tc, discovery.fn); |
| 366 | + }); |
| 367 | + } |
324 | 368 | });
|
325 | 369 | }
|
326 | 370 | });
|
@@ -350,7 +394,33 @@ describe("functions.yaml", function () {
|
350 | 394 |
|
351 | 395 | for (const tc of testcases) {
|
352 | 396 | describe(tc.name, () => {
|
353 |
| - runTests(tc); |
| 397 | + for (const discovery of discoveryMethods) { |
| 398 | + describe(`${discovery.name} discovery`, () => { |
| 399 | + runDiscoveryTests(tc, discovery.fn); |
| 400 | + }); |
| 401 | + } |
| 402 | + }); |
| 403 | + } |
| 404 | + }); |
| 405 | + |
| 406 | + describe("error handling", () => { |
| 407 | + const errorTestcases = [ |
| 408 | + { |
| 409 | + name: "broken syntax", |
| 410 | + modulePath: "./scripts/bin-test/sources/broken-syntax", |
| 411 | + expectedError: "missing ) after argument list", |
| 412 | + }, |
| 413 | + ]; |
| 414 | + |
| 415 | + for (const tc of errorTestcases) { |
| 416 | + describe(tc.name, () => { |
| 417 | + for (const discovery of discoveryMethods) { |
| 418 | + it(`${discovery.name} discovery handles error correctly`, async () => { |
| 419 | + const result = await discovery.fn(tc.modulePath); |
| 420 | + expect(result.success).to.be.false; |
| 421 | + expect(result.error).to.include(tc.expectedError); |
| 422 | + }); |
| 423 | + } |
354 | 424 | });
|
355 | 425 | }
|
356 | 426 | });
|
|
0 commit comments