Skip to content

Commit 607cc5a

Browse files
committed
Support adapter builds and add tests.
1 parent 67a33e7 commit 607cc5a

File tree

11 files changed

+272
-44
lines changed

11 files changed

+272
-44
lines changed

package-lock.json

Lines changed: 26 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/@apphosting/build/.gitignore

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
runs
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import * as assert from "assert";
2+
import { posix } from "path";
3+
import pkg from "@apphosting/common";
4+
import { scenarios } from "./scenarios.ts";
5+
import fsExtra from "fs-extra";
6+
import { parse as parseYaml } from "yaml";
7+
8+
const { readFileSync, mkdirp, rmdir, readJSONSync } = fsExtra;
9+
const { OutputBundleConfig } = pkg;
10+
11+
const scenario = process.env.SCENARIO;
12+
if (!scenario) {
13+
throw new Error("SCENARIO environment variable expected");
14+
}
15+
16+
const runId = process.env.RUN_ID;
17+
if (!runId) {
18+
throw new Error("RUN_ID environment variable expected");
19+
}
20+
21+
const bundleYaml = posix.join(process.cwd(), "e2e", "runs", runId, ".apphosting", "bundle.yaml");
22+
describe("supported framework apps", () => {
23+
it("apps have bundle.yaml correctly generated", async () => {
24+
const bundle = parseYaml(readFileSync(bundleYaml, "utf8")) as OutputBundleConfig;
25+
26+
assert.deepStrictEqual(scenarios.get(scenario).expectedBundleYaml.runConfig, bundle.runConfig);
27+
assert.deepStrictEqual(
28+
scenarios.get(scenario).expectedBundleYaml.metadata.adapterPackageName,
29+
bundle.metadata.adapterPackageName,
30+
);
31+
});
32+
});
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { cp } from "fs/promises";
2+
import promiseSpawn from "@npmcli/promise-spawn";
3+
import { dirname, join, relative } from "path";
4+
import { fileURLToPath } from "url";
5+
import { parse as parseYaml } from "yaml";
6+
import { spawn } from "child_process";
7+
import fsExtra from "fs-extra";
8+
import { scenarios } from "./scenarios.ts";
9+
10+
const { readFileSync, mkdirp, rmdir } = fsExtra;
11+
12+
const __dirname = dirname(fileURLToPath(import.meta.url));
13+
14+
const errors: any[] = [];
15+
16+
await rmdir(join(__dirname, "runs"), { recursive: true }).catch(() => undefined);
17+
18+
// Run each scenario
19+
for (const [scenarioName, scenario] of scenarios) {
20+
console.log(
21+
`\n\n${"=".repeat(80)}\n${" ".repeat(
22+
5,
23+
)}RUNNING SCENARIO: ${scenarioName.toUpperCase()}${" ".repeat(5)}\n${"=".repeat(80)}`,
24+
);
25+
26+
const runId = `${scenarioName}-${Math.random().toString().split(".")[1]}`;
27+
const cwd = join(__dirname, "runs", runId);
28+
await mkdirp(cwd);
29+
30+
const starterTemplateDir = scenarioName.includes("nextjs")
31+
? "../../../starters/nextjs/basic"
32+
: "../../../starters/angular/basic";
33+
console.log(`[${runId}] Copying ${starterTemplateDir} to working directory`);
34+
await cp(starterTemplateDir, cwd, { recursive: true });
35+
36+
// Run scenario-specific setup if provided
37+
if (scenario.setup) {
38+
console.log(`[${runId}] Running setup for ${scenarioName}`);
39+
await scenario.setup(cwd);
40+
}
41+
42+
console.log(`[${runId}] > npm ci --silent --no-progress`);
43+
await promiseSpawn("npm", ["ci", "--silent", "--no-progress"], {
44+
cwd,
45+
stdio: "inherit",
46+
shell: true,
47+
});
48+
49+
const buildScript = relative(cwd, join(__dirname, "../dist/bin/localbuild.js"));
50+
const buildLogPath = join(cwd, "build.log");
51+
console.log(`[${runId}] > node ${buildScript} (output written to ${buildLogPath})`);
52+
53+
const packageJson = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8"));
54+
const frameworkVersion = scenarioName.includes("nextjs")
55+
? packageJson.dependencies.next.replace("^", "")
56+
: JSON.parse(
57+
readFileSync(join(cwd, "node_modules", "@angular", "core", "package.json"), "utf-8"),
58+
).version;
59+
60+
try {
61+
await promiseSpawn("node", [buildScript, ...scenario.inputs], {
62+
cwd,
63+
stdioString: true,
64+
stdio: "pipe",
65+
shell: true,
66+
env: {
67+
...process.env,
68+
FRAMEWORK_VERSION: frameworkVersion,
69+
},
70+
}).then((result) => {
71+
// Write stdout and stderr to the log file
72+
fsExtra.writeFileSync(buildLogPath, result.stdout + result.stderr);
73+
});
74+
75+
try {
76+
// Determine which test files to run
77+
const testPattern = scenario.tests
78+
? scenario.tests.map((test) => `e2e/${test}`).join(" ")
79+
: "e2e/*.spec.ts";
80+
81+
console.log(`> SCENARIO=${scenarioName} ts-mocha -p tsconfig.json ${testPattern}`);
82+
83+
await promiseSpawn("ts-mocha", ["-p", "tsconfig.json", ...testPattern.split(" ")], {
84+
shell: true,
85+
stdio: "inherit",
86+
env: {
87+
...process.env,
88+
SCENARIO: scenarioName,
89+
RUN_ID: runId,
90+
},
91+
});
92+
} catch (e) {
93+
errors.push(e);
94+
}
95+
} catch (e) {
96+
console.error(`Error in scenario ${scenarioName}:`, e);
97+
errors.push(e);
98+
}
99+
100+
if (errors.length) {
101+
console.error(errors);
102+
process.exit(1);
103+
}
104+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import pkg from "@apphosting/common";
2+
const { OutputBundleConfig } = pkg;
3+
4+
interface Scenario {
5+
name: string; // Name of the scenario
6+
inputs: string[];
7+
expectedBundleYaml: OutputBundleConfig;
8+
tests?: string[]; // List of test files to run
9+
}
10+
11+
export const scenarios = new Map([
12+
[
13+
"nextjs-app",
14+
{
15+
inputs: ["./", "--framework", "nextjs"],
16+
expectedBundleYaml: {
17+
version: "v1",
18+
runConfig: {
19+
runCommand: "node .next/standalone/server.js",
20+
},
21+
metadata: {
22+
adapterPackageName: "@apphosting/adapter-nextjs",
23+
},
24+
},
25+
tests: ["adapter-builds.spec.ts"],
26+
},
27+
],
28+
[
29+
"angular-app",
30+
{
31+
inputs: ["./", "--framework", "angular"],
32+
expectedBundleYaml: {
33+
version: "v1",
34+
runConfig: {
35+
runCommand: "node dist/firebase-app-hosting-angular/server/server.mjs",
36+
environmentVariables: [],
37+
},
38+
metadata: {
39+
adapterPackageName: "@apphosting/adapter-angular",
40+
},
41+
},
42+
tests: ["adapter-builds.spec.ts"],
43+
},
44+
],
45+
]);

packages/@apphosting/build/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
"type": "module",
2222
"sideEffects": false,
2323
"scripts": {
24-
"build": "rm -rf dist && tsc && chmod +x ./dist/bin/*"
24+
"build": "rm -rf dist && tsc && chmod +x ./dist/bin/*",
25+
"test": "npm run test:functional",
26+
"test:functional": "node --loader ts-node/esm ./e2e/run-local-build.ts"
2527
},
2628
"exports": {
2729
".": {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { spawn } from "child_process";
2+
import { yellow, bgRed, bold } from "colorette";
3+
4+
export async function adapterBuild(projectRoot: string, framework: string) {
5+
// TODO(#382): We are using the latest framework adapter versions, but in the future
6+
// we should parse the framework version and use the matching adapter version.
7+
const adapterName = `@apphosting/adapter-${framework}`;
8+
const packumentResponse = await fetch(`https://registry.npmjs.org/${adapterName}`);
9+
if (!packumentResponse.ok)
10+
throw new Error(
11+
`Failed to fetch ${adapterName}: ${packumentResponse.status} ${packumentResponse.statusText}`,
12+
);
13+
const packument = await packumentResponse.json();
14+
const adapterVersion = packument?.["dist-tags"]?.["latest"];
15+
if (!adapterVersion) {
16+
throw new Error(`Could not find 'latest' dist-tag for ${adapterName}`);
17+
}
18+
// TODO(#382): should check for existence of adapter in app's package.json and use that version instead.
19+
20+
console.log(" 🔥", bgRed(` ${adapterName}@${yellow(bold(adapterVersion))} `), "\n");
21+
22+
const buildCommand = `apphosting-adapter-${framework}-build`;
23+
await new Promise<void>((resolve, reject) => {
24+
const child = spawn("npx", ["-y", "-p", `${adapterName}@${adapterVersion}`, buildCommand], {
25+
cwd: projectRoot,
26+
shell: true,
27+
stdio: "inherit",
28+
});
29+
30+
child.on("error", reject);
31+
32+
child.on("exit", (code) => {
33+
if (code !== 0) {
34+
reject(new Error(`framework adapter build failed with error code ${code}.`));
35+
}
36+
resolve();
37+
});
38+
});
39+
}
Lines changed: 18 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,28 @@
11
#! /usr/bin/env node
2-
import { spawn } from "child_process";
2+
import { SupportedFrameworks, ApphostingConfig } from "@apphosting/common";
3+
import { adapterBuild } from "../adapter-builds.js";
4+
import { parse as parseYaml } from "yaml";
5+
import fsExtra from "fs-extra";
6+
import { join } from "path";
37
import { program } from "commander";
4-
import { yellow, bgRed, bold } from "colorette";
58

6-
// TODO(#382): add framework option later or incorporate micro-discovery.
7-
// TODO(#382): parse apphosting.yaml for environment variables / secrets.
8-
// TODO(#382): parse apphosting.yaml for runConfig and include in buildSchema
9-
// TODO(#382): Support custom build and run commands (parse and pass run command to build schema).
9+
export const { readFileSync } = fsExtra;
10+
1011
program
1112
.argument("<projectRoot>", "path to the project's root directory")
12-
.action(async (projectRoot: string) => {
13-
const framework = "nextjs";
14-
// TODO(#382): We are using the latest framework adapter versions, but in the future
15-
// we should parse the framework version and use the matching adapter version.
16-
const adapterName = `@apphosting/adapter-nextjs`;
17-
const packumentResponse = await fetch(`https://registry.npmjs.org/${adapterName}`);
18-
if (!packumentResponse.ok) throw new Error(`Something went wrong fetching ${adapterName}`);
19-
const packument = await packumentResponse.json();
20-
const adapterVersion = packument?.["dist-tags"]?.["latest"];
21-
if (!adapterVersion) {
22-
throw new Error(`Could not find 'latest' dist-tag for ${adapterName}`);
23-
}
24-
// TODO(#382): should check for existence of adapter in app's package.json and use that version instead.
13+
.option("--framework <framework>")
14+
.action(async (projectRoot, opts) => {
15+
// TODO(#382): support other apphosting.*.yaml files.
2516

26-
console.log(" 🔥", bgRed(` ${adapterName}@${yellow(bold(adapterVersion))} `), "\n");
27-
28-
const buildCommand = `apphosting-adapter-${framework}-build`;
29-
await new Promise<void>((resolve, reject) => {
30-
const child = spawn("npx", ["-y", "-p", `${adapterName}@${adapterVersion}`, buildCommand], {
31-
cwd: projectRoot,
32-
shell: true,
33-
stdio: "inherit",
34-
});
17+
// TODO(#382): parse apphosting.yaml for environment variables / secrets needed during build time.
18+
if (opts.framework && SupportedFrameworks.includes(opts.framework)) {
19+
// TODO(#382): Skip this if there's a custom build command in apphosting.yaml.
20+
adapterBuild(projectRoot, opts.framework);
21+
}
3522

36-
child.on("exit", (code) => {
37-
if (code !== 0) {
38-
reject(new Error(`framework adapter build failed with error code ${code}.`));
39-
}
40-
resolve();
41-
});
42-
});
43-
// TODO(#382): parse bundle.yaml and apphosting.yaml and output a buildschema somewhere.
23+
// TODO(#382): Parse apphosting.yaml to set custom run command in bundle.yaml
24+
// TODO(#382): parse apphosting.yaml for environment variables / secrets needed during runtime to include in the bunde.yaml
25+
// TODO(#382): parse apphosting.yaml for runConfig to include in bundle.yaml
4426
});
4527

4628
program.parse();

packages/@apphosting/build/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
},
99
"include": [
1010
"src/index.ts",
11-
"src/bin/*.ts",
11+
"src/bin/*.ts"
1212
],
1313
"exclude": [
1414
"src/*.spec.ts"

0 commit comments

Comments
 (0)