diff --git a/package-lock.json b/package-lock.json index 00c3be98..cd97d2cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25652,7 +25652,7 @@ } }, "packages/@apphosting/build": { - "version": "0.1.0", + "version": "0.1.1", "license": "Apache-2.0", "dependencies": { "@apphosting/discover": "*", @@ -25667,17 +25667,9 @@ }, "devDependencies": { "@types/commander": "*", - "@types/fs-extra": "*", - "@types/mocha": "*", - "@types/tmp": "*", - "mocha": "*", - "next": "~14.0.0", - "semver": "*", - "tmp": "*", "ts-mocha": "*", "ts-node": "*", - "typescript": "*", - "verdaccio": "^5.30.3" + "typescript": "*" } }, "packages/@apphosting/build/node_modules/hosted-git-info": { @@ -25735,6 +25727,20 @@ "node": "^16.14.0 || >=18.0.0" } }, + "packages/@apphosting/build/node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "packages/@apphosting/common": { "version": "0.0.7", "license": "Apache-2.0" diff --git a/packages/@apphosting/build/.gitignore b/packages/@apphosting/build/.gitignore deleted file mode 100644 index 66e7f6a2..00000000 --- a/packages/@apphosting/build/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/e2e \ No newline at end of file diff --git a/packages/@apphosting/build/e2e/.gitignore b/packages/@apphosting/build/e2e/.gitignore new file mode 100644 index 00000000..cb1d07bf --- /dev/null +++ b/packages/@apphosting/build/e2e/.gitignore @@ -0,0 +1 @@ +runs \ No newline at end of file diff --git a/packages/@apphosting/build/e2e/adapter-builds.spec.ts b/packages/@apphosting/build/e2e/adapter-builds.spec.ts new file mode 100644 index 00000000..5d82074d --- /dev/null +++ b/packages/@apphosting/build/e2e/adapter-builds.spec.ts @@ -0,0 +1,33 @@ +import * as assert from "assert"; +import { posix } from "path"; +import pkg from "@apphosting/common"; +import { scenarios } from "./scenarios.ts"; +import fsExtra from "fs-extra"; +import { parse as parseYaml } from "yaml"; + +const { readFileSync } = fsExtra; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const { OutputBundleConfig } = pkg; + +const scenario = process.env.SCENARIO; +if (!scenario) { + throw new Error("SCENARIO environment variable expected"); +} + +const runId = process.env.RUN_ID; +if (!runId) { + throw new Error("RUN_ID environment variable expected"); +} + +const bundleYaml = posix.join(process.cwd(), "e2e", "runs", runId, ".apphosting", "bundle.yaml"); +describe("supported framework apps", () => { + it("apps have bundle.yaml correctly generated", () => { + const bundle: OutputBundleConfig = parseYaml(readFileSync(bundleYaml, "utf8")); + + assert.deepStrictEqual(scenarios.get(scenario).expectedBundleYaml.runConfig, bundle.runConfig); + assert.deepStrictEqual( + scenarios.get(scenario).expectedBundleYaml.metadata.adapterPackageName, + bundle.metadata.adapterPackageName, + ); + }); +}); diff --git a/packages/@apphosting/build/e2e/run-local-build.ts b/packages/@apphosting/build/e2e/run-local-build.ts new file mode 100644 index 00000000..990cab29 --- /dev/null +++ b/packages/@apphosting/build/e2e/run-local-build.ts @@ -0,0 +1,94 @@ +import { cp } from "fs/promises"; +import promiseSpawn from "@npmcli/promise-spawn"; +import { dirname, join, relative } from "path"; +import { fileURLToPath } from "url"; +import fsExtra from "fs-extra"; +import { scenarios } from "./scenarios.js"; + +const { readFileSync, mkdirp, rmdir } = fsExtra; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const errors: any[] = []; + +await rmdir(join(__dirname, "runs"), { recursive: true }).catch(() => undefined); + +// Run each scenario +for (const [scenarioName, scenario] of scenarios) { + console.log( + `\n\n${"=".repeat(80)}\n${" ".repeat( + 5, + )}RUNNING SCENARIO: ${scenarioName.toUpperCase()}${" ".repeat(5)}\n${"=".repeat(80)}`, + ); + + const runId = `${scenarioName}-${Math.random().toString().split(".")[1]}`; + const cwd = join(__dirname, "runs", runId); + await mkdirp(cwd); + + const starterTemplateDir = scenarioName.includes("nextjs") + ? "../../../starters/nextjs/basic" + : "../../../starters/angular/basic"; + console.log(`[${runId}] Copying ${starterTemplateDir} to working directory`); + await cp(starterTemplateDir, cwd, { recursive: true }); + + console.log(`[${runId}] > npm ci --silent --no-progress`); + await promiseSpawn("npm", ["ci", "--silent", "--no-progress"], { + cwd, + stdio: "inherit", + shell: true, + }); + + const buildScript = relative(cwd, join(__dirname, "../dist/bin/localbuild.js")); + const buildLogPath = join(cwd, "build.log"); + console.log(`[${runId}] > node ${buildScript} (output written to ${buildLogPath})`); + + const packageJson = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8")); + const frameworkVersion = scenarioName.includes("nextjs") + ? packageJson.dependencies.next.replace("^", "") + : JSON.parse( + readFileSync(join(cwd, "node_modules", "@angular", "core", "package.json"), "utf-8"), + ).version; + + try { + const result = await promiseSpawn("node", [buildScript, ...scenario.inputs], { + cwd, + stdioString: true, + stdio: "pipe", + shell: true, + env: { + ...process.env, + FRAMEWORK_VERSION: frameworkVersion, + }, + }); + // Write stdout and stderr to the log file + fsExtra.writeFileSync(buildLogPath, result.stdout + result.stderr); + + try { + // Determine which test files to run + const testPattern = scenario.tests + ? scenario.tests.map((test) => `e2e/${test}`).join(" ") + : "e2e/*.spec.ts"; + + console.log(`> SCENARIO=${scenarioName} ts-mocha -p tsconfig.json ${testPattern}`); + + await promiseSpawn("ts-mocha", ["-p", "tsconfig.json", ...testPattern.split(" ")], { + shell: true, + stdio: "inherit", + env: { + ...process.env, + SCENARIO: scenarioName, + RUN_ID: runId, + }, + }); + } catch (e) { + errors.push(e); + } + } catch (e) { + console.error(`Error in scenario ${scenarioName}:`, e); + errors.push(e); + } +} +if (errors.length) { + console.error(errors); + process.exit(1); +} diff --git a/packages/@apphosting/build/e2e/scenarios.ts b/packages/@apphosting/build/e2e/scenarios.ts new file mode 100644 index 00000000..d1a17839 --- /dev/null +++ b/packages/@apphosting/build/e2e/scenarios.ts @@ -0,0 +1,41 @@ +interface Scenario { + inputs: string[]; + expectedBundleYaml: {}; + tests?: string[]; // List of test files to run +} + +export const scenarios: Map = new Map([ + [ + "nextjs-app", + { + inputs: ["./", "--framework", "nextjs"], + expectedBundleYaml: { + version: "v1", + runConfig: { + runCommand: "node .next/standalone/server.js", + }, + metadata: { + adapterPackageName: "@apphosting/adapter-nextjs", + }, + }, + tests: ["adapter-builds.spec.ts"], + }, + ], + [ + "angular-app", + { + inputs: ["./", "--framework", "angular"], + expectedBundleYaml: { + version: "v1", + runConfig: { + runCommand: "node dist/firebase-app-hosting-angular/server/server.mjs", + environmentVariables: [], + }, + metadata: { + adapterPackageName: "@apphosting/adapter-angular", + }, + }, + tests: ["adapter-builds.spec.ts"], + }, + ], +]); diff --git a/packages/@apphosting/build/package.json b/packages/@apphosting/build/package.json index babd8c4a..00b84fc9 100644 --- a/packages/@apphosting/build/package.json +++ b/packages/@apphosting/build/package.json @@ -8,8 +8,8 @@ "url": "git+https://github.com/FirebaseExtended/firebase-framework-tools.git" }, "bin": { - "build": "dist/bin/build.js", - "apphosting-local-build": "dist/bin/localbuild.js" + "build": "dist/bin/build.js", + "apphosting-local-build": "dist/bin/localbuild.js" }, "author": { "name": "Firebase", @@ -21,12 +21,18 @@ "type": "module", "sideEffects": false, "scripts": { - "build": "rm -rf dist && tsc && chmod +x ./dist/bin/*" + "build": "rm -rf dist && tsc && chmod +x ./dist/bin/*", + "test": "npm run test:functional", + "test:functional": "node --loader ts-node/esm ./e2e/run-local-build.ts" }, "exports": { ".": { "node": "./dist/index.js", "default": null + }, + "./dist/*": { + "node": "./dist/*", + "default": null } }, "files": [ @@ -41,6 +47,9 @@ "ts-node": "^10.9.1" }, "devDependencies": { - "@types/commander": "*" + "@types/commander": "*", + "ts-mocha": "*", + "ts-node": "*", + "typescript": "*" } } diff --git a/packages/@apphosting/build/src/adapter-builds.ts b/packages/@apphosting/build/src/adapter-builds.ts new file mode 100644 index 00000000..07a0b428 --- /dev/null +++ b/packages/@apphosting/build/src/adapter-builds.ts @@ -0,0 +1,33 @@ +import promiseSpawn from "@npmcli/promise-spawn"; +import { yellow, bgRed, bold } from "colorette"; + +export async function adapterBuild(projectRoot: string, framework: string) { + // TODO(#382): We are using the latest framework adapter versions, but in the future + // we should parse the framework version and use the matching adapter version. + const adapterName = `@apphosting/adapter-${framework}`; + const packumentResponse = await fetch(`https://registry.npmjs.org/${adapterName}`); + if (!packumentResponse.ok) + throw new Error( + `Failed to fetch ${adapterName}: ${packumentResponse.status} ${packumentResponse.statusText}`, + ); + let packument; + try { + packument = await packumentResponse.json(); + } catch (e) { + throw new Error(`Failed to parse response from NPM registry for ${adapterName}.`); + } + const adapterVersion = packument?.["dist-tags"]?.["latest"]; + if (!adapterVersion) { + throw new Error(`Could not find 'latest' dist-tag for ${adapterName}`); + } + // TODO(#382): should check for existence of adapter in app's package.json and use that version instead. + + console.log(" 🔥", bgRed(` ${adapterName}@${yellow(bold(adapterVersion))} `), "\n"); + + const buildCommand = `apphosting-adapter-${framework}-build`; + await promiseSpawn("npx", ["-y", "-p", `${adapterName}@${adapterVersion}`, buildCommand], { + cwd: projectRoot, + shell: true, + stdio: "inherit", + }); +} diff --git a/packages/@apphosting/build/src/bin/localbuild.ts b/packages/@apphosting/build/src/bin/localbuild.ts index ed284df9..d118a909 100644 --- a/packages/@apphosting/build/src/bin/localbuild.ts +++ b/packages/@apphosting/build/src/bin/localbuild.ts @@ -1,46 +1,23 @@ #! /usr/bin/env node -import { spawn } from "child_process"; +import { SupportedFrameworks } from "@apphosting/common"; +import { adapterBuild } from "../adapter-builds.js"; import { program } from "commander"; -import { yellow, bgRed, bold } from "colorette"; -// TODO(#382): add framework option later or incorporate micro-discovery. -// TODO(#382): parse apphosting.yaml for environment variables / secrets. -// TODO(#382): parse apphosting.yaml for runConfig and include in buildSchema -// TODO(#382): Support custom build and run commands (parse and pass run command to build schema). program .argument("", "path to the project's root directory") - .action(async (projectRoot: string) => { - const framework = "nextjs"; - // TODO(#382): We are using the latest framework adapter versions, but in the future - // we should parse the framework version and use the matching adapter version. - const adapterName = `@apphosting/adapter-nextjs`; - const packumentResponse = await fetch(`https://registry.npmjs.org/${adapterName}`); - if (!packumentResponse.ok) throw new Error(`Something went wrong fetching ${adapterName}`); - const packument = await packumentResponse.json(); - const adapterVersion = packument?.["dist-tags"]?.["latest"]; - if (!adapterVersion) { - throw new Error(`Could not find 'latest' dist-tag for ${adapterName}`); - } - // TODO(#382): should check for existence of adapter in app's package.json and use that version instead. - - console.log(" 🔥", bgRed(` ${adapterName}@${yellow(bold(adapterVersion))} `), "\n"); + .option("--framework ") + .action(async (projectRoot, opts) => { + // TODO(#382): support other apphosting.*.yaml files. - const buildCommand = `apphosting-adapter-${framework}-build`; - await new Promise((resolve, reject) => { - const child = spawn("npx", ["-y", "-p", `${adapterName}@${adapterVersion}`, buildCommand], { - cwd: projectRoot, - shell: true, - stdio: "inherit", - }); + // TODO(#382): parse apphosting.yaml for environment variables / secrets needed during build time. + if (opts.framework && SupportedFrameworks.includes(opts.framework)) { + // TODO(#382): Skip this if there's a custom build command in apphosting.yaml. + await adapterBuild(projectRoot, opts.framework); + } - child.on("exit", (code) => { - if (code !== 0) { - reject(new Error(`framework adapter build failed with error code ${code}.`)); - } - resolve(); - }); - }); - // TODO(#382): parse bundle.yaml and apphosting.yaml and output a buildschema somewhere. + // TODO(#382): Parse apphosting.yaml to set custom run command in bundle.yaml + // TODO(#382): parse apphosting.yaml for environment variables / secrets needed during runtime to include in the bunde.yaml + // TODO(#382): parse apphosting.yaml for runConfig to include in bundle.yaml }); program.parse(); diff --git a/packages/@apphosting/build/tsconfig.json b/packages/@apphosting/build/tsconfig.json index 3041a86e..91a956e6 100644 --- a/packages/@apphosting/build/tsconfig.json +++ b/packages/@apphosting/build/tsconfig.json @@ -4,13 +4,18 @@ "noEmit": false, "outDir": "dist", "rootDir": "src" - + }, + "ts-node": { + "esm": true, + "logError": true, + "pretty": true }, "include": [ "src/index.ts", "src/bin/*.ts", ], "exclude": [ - "src/*.spec.ts" + "src/*.spec.ts", + "src/bin/build.ts" ] } diff --git a/packages/@apphosting/common/package.json b/packages/@apphosting/common/package.json index 28bfbf6e..22a0540c 100644 --- a/packages/@apphosting/common/package.json +++ b/packages/@apphosting/common/package.json @@ -1,6 +1,6 @@ { "name": "@apphosting/common", - "version": "0.0.7", + "version": "0.0.8", "description": "Shared library code for App Hosting framework adapters", "author": { "name": "Firebase", diff --git a/packages/@apphosting/common/src/index.ts b/packages/@apphosting/common/src/index.ts index f52f32c2..a11f239e 100644 --- a/packages/@apphosting/common/src/index.ts +++ b/packages/@apphosting/common/src/index.ts @@ -2,6 +2,9 @@ import { spawn } from "child_process"; import * as fs from "node:fs"; import * as path from "node:path"; +// List of apphosting supported frameworks. +export const SupportedFrameworks = ["nextjs", "angular"] as const; + // **** OutputBundleConfig interfaces **** // Output bundle metadata specifications to be written to bundle.yaml