Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions scripts/bin-test/sources/broken-syntax/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const functions = require("firebase-functions");

// This will cause a syntax error
exports.broken = functions.https.onRequest((request, response) => {
response.send("Hello from Firebase!"
}); // Missing closing parenthesis
3 changes: 3 additions & 0 deletions scripts/bin-test/sources/broken-syntax/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"name": "broken-syntax"
}
197 changes: 126 additions & 71 deletions scripts/bin-test/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,13 @@ const BASE_STACK = {
interface Testcase {
name: string;
modulePath: string;
expected: Record<string, any>;
expected: Record<string, unknown>;
}

interface DiscoveryResult {
success: boolean;
manifest?: Record<string, unknown>;
error?: string;
}

async function retryUntil(
Expand Down Expand Up @@ -134,102 +140,121 @@ async function retryUntil(
await Promise.race([retry, timedOut]);
}

async function startBin(
tc: Testcase,
debug?: boolean
): Promise<{ port: number; cleanup: () => Promise<void> }> {
async function runHttpDiscovery(modulePath: string): Promise<DiscoveryResult> {
const getPort = promisify(portfinder.getPort) as () => Promise<number>;
const port = await getPort();

const proc = subprocess.spawn("npx", ["firebase-functions"], {
cwd: path.resolve(tc.modulePath),
cwd: path.resolve(modulePath),
env: {
PATH: process.env.PATH,
GLCOUD_PROJECT: "test-project",
GCLOUD_PROJECT: "test-project",
PORT: port.toString(),
FUNCTIONS_CONTROL_API: "true",
},
});
if (!proc) {
throw new Error("Failed to start firebase functions");
}
proc.stdout?.on("data", (chunk: Buffer) => {
console.log(chunk.toString("utf8"));
});
proc.stderr?.on("data", (chunk: Buffer) => {
console.log(chunk.toString("utf8"));
});

await retryUntil(async () => {
try {
await fetch(`http://localhost:${port}/__/functions.yaml`);
} catch (e) {
if (e?.code === "ECONNREFUSED") {
return false;
try {
// Wait for server to be ready
await retryUntil(async () => {
try {
await fetch(`http://localhost:${port}/__/functions.yaml`);
return true;
} catch (e: unknown) {
const error = e as { code?: string };
if (error.code === "ECONNREFUSED") {
// This is an expected error during server startup, so we should retry.
return false;
}
// Any other error is unexpected and should fail the test immediately.
throw e;
}
throw e;
}, TIMEOUT_L);

const res = await fetch(`http://localhost:${port}/__/functions.yaml`);
const body = await res.text();

if (res.status === 200) {
const manifest = yaml.load(body) as Record<string, unknown>;
return { success: true, manifest };
} else {
return { success: false, error: body };
}
return true;
}, TIMEOUT_L);
} finally {
proc.kill(9);
}
}

if (debug) {
proc.stdout?.on("data", (data: unknown) => {
console.log(`[${tc.name} stdout] ${data}`);
async function runStdioDiscovery(modulePath: string): Promise<DiscoveryResult> {
return new Promise((resolve, reject) => {
const proc = subprocess.spawn("npx", ["firebase-functions"], {
cwd: path.resolve(modulePath),
env: {
PATH: process.env.PATH,
GCLOUD_PROJECT: "test-project",
FUNCTIONS_CONTROL_API: "true",
FUNCTIONS_DISCOVERY_MODE: "stdio",
},
});

proc.stderr?.on("data", (data: unknown) => {
console.log(`[${tc.name} stderr] ${data}`);
let stderr = "";

proc.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
});
}

return {
port,
cleanup: async () => {
process.kill(proc.pid, 9);
await retryUntil(async () => {
try {
process.kill(proc.pid, 0);
} catch {
// process.kill w/ signal 0 will throw an error if the pid no longer exists.
return Promise.resolve(true);
}
return Promise.resolve(false);
}, TIMEOUT_L);
},
};
}
const timeoutId = setTimeout(() => {
proc.kill(9);
reject(new Error(`Stdio discovery timed out after ${TIMEOUT_M}ms`));
}, TIMEOUT_M);

describe("functions.yaml", function () {
// eslint-disable-next-line @typescript-eslint/no-invalid-this
this.timeout(TIMEOUT_XL);
proc.on("close", () => {
clearTimeout(timeoutId);
const manifestMatch = stderr.match(/__FIREBASE_FUNCTIONS_MANIFEST__:([\s\S]+)/);
if (manifestMatch) {
const base64 = manifestMatch[1];
const manifestJson = Buffer.from(base64, "base64").toString("utf8");
const manifest = JSON.parse(manifestJson) as Record<string, unknown>;
resolve({ success: true, manifest });
return;
}

function runTests(tc: Testcase) {
let port: number;
let cleanup: () => Promise<void>;
const errorMatch = stderr.match(/__FIREBASE_FUNCTIONS_MANIFEST_ERROR__:([\s\S]+)/);
if (errorMatch) {
resolve({ success: false, error: errorMatch[1] });
return;
}

before(async () => {
const r = await startBin(tc);
port = r.port;
cleanup = r.cleanup;
resolve({ success: false, error: "No manifest or error found" });
});

after(async () => {
await cleanup?.();
proc.on("error", (err) => {
clearTimeout(timeoutId);
reject(err);
});
});
}

it("functions.yaml returns expected Manifest", async function () {
describe("functions.yaml", function () {
// eslint-disable-next-line @typescript-eslint/no-invalid-this
this.timeout(TIMEOUT_XL);

const discoveryMethods = [
{ name: "http", fn: runHttpDiscovery },
{ name: "stdio", fn: runStdioDiscovery },
];

function runDiscoveryTests(
tc: Testcase,
discoveryFn: (path: string) => Promise<DiscoveryResult>
) {
it("returns expected manifest", async function () {
// eslint-disable-next-line @typescript-eslint/no-invalid-this
this.timeout(TIMEOUT_M);

const res = await fetch(`http://localhost:${port}/__/functions.yaml`);
const text = await res.text();
let parsed: any;
try {
parsed = yaml.load(text);
} catch (err) {
throw new Error(`Failed to parse functions.yaml: ${err}`);
}
expect(parsed).to.be.deep.equal(tc.expected);
const result = await discoveryFn(tc.modulePath);
expect(result.success).to.be.true;
expect(result.manifest).to.deep.equal(tc.expected);
});
}

Expand Down Expand Up @@ -320,7 +345,11 @@ describe("functions.yaml", function () {

for (const tc of testcases) {
describe(tc.name, () => {
runTests(tc);
for (const discovery of discoveryMethods) {
describe(`${discovery.name} discovery`, () => {
runDiscoveryTests(tc, discovery.fn);
});
}
});
}
});
Expand Down Expand Up @@ -350,7 +379,33 @@ describe("functions.yaml", function () {

for (const tc of testcases) {
describe(tc.name, () => {
runTests(tc);
for (const discovery of discoveryMethods) {
describe(`${discovery.name} discovery`, () => {
runDiscoveryTests(tc, discovery.fn);
});
}
});
}
});

describe("error handling", () => {
const errorTestcases = [
{
name: "broken syntax",
modulePath: "./scripts/bin-test/sources/broken-syntax",
expectedError: "missing ) after argument list",
},
];

for (const tc of errorTestcases) {
describe(tc.name, () => {
for (const discovery of discoveryMethods) {
it(`${discovery.name} discovery handles error correctly`, async () => {
const result = await discovery.fn(tc.modulePath);
expect(result.success).to.be.false;
expect(result.error).to.include(tc.expectedError);
});
}
});
}
});
Expand Down
74 changes: 51 additions & 23 deletions src/bin/firebase-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,34 +49,62 @@ if (args.length > 1) {
functionsDir = args[0];
}

let server: http.Server = undefined;
const app = express();
const MANIFEST_PREFIX = "__FIREBASE_FUNCTIONS_MANIFEST__:";
const MANIFEST_ERROR_PREFIX = "__FIREBASE_FUNCTIONS_MANIFEST_ERROR__:";

function handleQuitquitquit(req: express.Request, res: express.Response) {
async function runStdioDiscovery() {
try {
const stack = await loadStack(functionsDir);
const wireFormat = stackToWire(stack);
const manifestJson = JSON.stringify(wireFormat);
const base64 = Buffer.from(manifestJson).toString("base64");
process.stderr.write(`${MANIFEST_PREFIX}${base64}\n`);
process.exitCode = 0;
} catch (e) {
console.error("Failed to generate manifest from function source:", e);
const message = e instanceof Error ? e.message : String(e);
process.stderr.write(`${MANIFEST_ERROR_PREFIX}${message}\n`);
process.exitCode = 1;
}
}

function handleQuitquitquit(req: express.Request, res: express.Response, server: http.Server) {
res.send("ok");
server.close();
}

app.get("/__/quitquitquit", handleQuitquitquit);
app.post("/__/quitquitquit", handleQuitquitquit);
if (
process.env.FUNCTIONS_CONTROL_API === "true" &&
process.env.FUNCTIONS_DISCOVERY_MODE === "stdio"
) {
void runStdioDiscovery();
} else {
let server: http.Server = undefined;
const app = express();

if (process.env.FUNCTIONS_CONTROL_API === "true") {
app.get("/__/functions.yaml", async (req, res) => {
try {
const stack = await loadStack(functionsDir);
res.setHeader("content-type", "text/yaml");
res.send(JSON.stringify(stackToWire(stack)));
} catch (e) {
console.error(e);
res.status(400).send(`Failed to generate manifest from function source: ${e}`);
}
});
}
app.get("/__/quitquitquit", (req, res) => handleQuitquitquit(req, res, server));
app.post("/__/quitquitquit", (req, res) => handleQuitquitquit(req, res, server));

let port = 8080;
if (process.env.PORT) {
port = Number.parseInt(process.env.PORT);
}
if (process.env.FUNCTIONS_CONTROL_API === "true") {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
app.get("/__/functions.yaml", async (req, res) => {
try {
const stack = await loadStack(functionsDir);
res.setHeader("content-type", "text/yaml");
res.send(JSON.stringify(stackToWire(stack)));
} catch (e) {
console.error(e);
const errorMessage = e instanceof Error ? e.message : String(e);
res.status(400).send(`Failed to generate manifest from function source: ${errorMessage}`);
}
});
}

let port = 8080;
if (process.env.PORT) {
port = Number.parseInt(process.env.PORT);
}

console.log("Serving at port", port);
server = app.listen(port);
console.log("Serving at port", port);
server = app.listen(port);
}
Loading