diff --git a/src/frameworks/compose/discover/filesystem.ts b/src/frameworks/compose/discover/filesystem.ts new file mode 100644 index 00000000000..b2c7b7f9b47 --- /dev/null +++ b/src/frameworks/compose/discover/filesystem.ts @@ -0,0 +1,55 @@ +import { FileSystem } from "./types"; +import { pathExists, readFile } from "fs-extra"; +import * as path from "path"; +import { FirebaseError } from "../../../error"; +import { logger } from "../../../../src/logger"; + +/** + * Find files or read file contents present in the directory. + */ +export class LocalFileSystem implements FileSystem { + private readonly existsCache: Record = {}; + private readonly contentCache: Record = {}; + + constructor(private readonly cwd: string) {} + + async exists(file: string): Promise { + try { + if (!(file in this.contentCache)) { + this.existsCache[file] = await pathExists(path.resolve(this.cwd, file)); + } + + return this.existsCache[file]; + } catch (error) { + throw new FirebaseError(`Error occured while searching for file: ${error}`); + } + } + + async read(file: string): Promise { + try { + if (!(file in this.contentCache)) { + const fileContents = await readFile(path.resolve(this.cwd, file), "utf-8"); + this.contentCache[file] = fileContents; + } + return this.contentCache[file]; + } catch (error) { + logger.error("Error occured while reading file contents."); + throw error; + } + } +} + +/** + * Convert ENOENT errors into null + */ +export async function readOrNull(fs: FileSystem, path: string): Promise { + try { + return fs.read(path); + } catch (err: any) { + if (err && typeof err === "object" && err?.code === "ENOENT") { + logger.debug("ENOENT error occured while reading file."); + return null; + } + throw new Error(`Unknown error occured while reading file: ${err}`); + } +} diff --git a/src/frameworks/compose/discover/frameworkMatcher.ts b/src/frameworks/compose/discover/frameworkMatcher.ts new file mode 100644 index 00000000000..53748e983c4 --- /dev/null +++ b/src/frameworks/compose/discover/frameworkMatcher.ts @@ -0,0 +1,103 @@ +import { FirebaseError } from "../../../error"; +import { FrameworkSpec, FileSystem } from "./types"; +import { logger } from "../../../logger"; + +export function filterFrameworksWithDependencies( + allFrameworkSpecs: FrameworkSpec[], + dependencies: Record +): FrameworkSpec[] { + return allFrameworkSpecs.filter((framework) => { + return framework.requiredDependencies.every((dependency) => { + return dependency.name in dependencies; + }); + }); +} + +export async function filterFrameworksWithFiles( + allFrameworkSpecs: FrameworkSpec[], + fs: FileSystem +): Promise { + try { + const filteredFrameworks = []; + for (const framework of allFrameworkSpecs) { + if (!framework.requiredFiles) { + filteredFrameworks.push(framework); + continue; + } + let isRequired = true; + for (let files of framework.requiredFiles) { + files = Array.isArray(files) ? files : [files]; + for (const file of files) { + isRequired = isRequired && (await fs.exists(file)); + if (!isRequired) { + break; + } + } + } + if (isRequired) { + filteredFrameworks.push(framework); + } + } + + return filteredFrameworks; + } catch (error) { + logger.error("Error: Unable to filter frameworks based on required files", error); + throw error; + } +} + +/** + * Embeded frameworks help to resolve tiebreakers when multiple frameworks are discovered. + * Ex: "next" embeds "react", so if both frameworks are discovered, + * we can suggest "next" commands by removing its embeded framework (react). + */ +export function removeEmbededFrameworks(allFrameworkSpecs: FrameworkSpec[]): FrameworkSpec[] { + const embededFrameworkSet: Set = new Set(); + + for (const framework of allFrameworkSpecs) { + if (!framework.embedsFrameworks) { + continue; + } + for (const item of framework.embedsFrameworks) { + embededFrameworkSet.add(item); + } + } + + return allFrameworkSpecs.filter((item) => !embededFrameworkSet.has(item.id)); +} + +/** + * Identifies the best FrameworkSpec for the codebase. + */ +export async function frameworkMatcher( + runtime: string, + fs: FileSystem, + frameworks: FrameworkSpec[], + dependencies: Record +): Promise { + try { + const filterRuntimeFramework = frameworks.filter((framework) => framework.runtime === runtime); + const frameworksWithDependencies = filterFrameworksWithDependencies( + filterRuntimeFramework, + dependencies + ); + const frameworkWithFiles = await filterFrameworksWithFiles(frameworksWithDependencies, fs); + const allMatches = removeEmbededFrameworks(frameworkWithFiles); + + if (allMatches.length === 0) { + return null; + } + if (allMatches.length > 1) { + const frameworkNames = allMatches.map((framework) => framework.id); + throw new FirebaseError( + `Multiple Frameworks are matched: ${frameworkNames.join( + ", " + )} Manually set up override commands in firebase.json` + ); + } + + return allMatches[0]; + } catch (error: any) { + throw new FirebaseError(`Failed to match the correct framework: ${error}`); + } +} diff --git a/src/frameworks/compose/discover/frameworkSpec.ts b/src/frameworks/compose/discover/frameworkSpec.ts new file mode 100644 index 00000000000..70655a85b07 --- /dev/null +++ b/src/frameworks/compose/discover/frameworkSpec.ts @@ -0,0 +1,38 @@ +import { FrameworkSpec } from "./types"; + +export const frameworkSpecs: FrameworkSpec[] = [ + { + id: "express", + runtime: "nodejs", + webFrameworkId: "Express.js", + requiredDependencies: [ + { + name: "express", + }, + ], + }, + { + id: "nextjs", + runtime: "nodejs", + webFrameworkId: "Next.js", + requiredFiles: ["next.config.js", "next.config.ts"], + requiredDependencies: [ + { + name: "next", + }, + ], + commands: { + build: { + cmd: "next build", + }, + dev: { + cmd: "next dev", + env: { NODE_ENV: "dev" }, + }, + run: { + cmd: "next run", + env: { NODE_ENV: "production" }, + }, + }, + }, +]; diff --git a/src/frameworks/compose/discover/runtime/node.ts b/src/frameworks/compose/discover/runtime/node.ts new file mode 100644 index 00000000000..a6d686b014e --- /dev/null +++ b/src/frameworks/compose/discover/runtime/node.ts @@ -0,0 +1,252 @@ +import { readOrNull } from "../filesystem"; +import { FileSystem, FrameworkSpec, Runtime } from "../types"; +import { RuntimeSpec } from "../types"; +import { frameworkMatcher } from "../frameworkMatcher"; +import { LifecycleCommands } from "../types"; +import { Command } from "../types"; +import { join } from "path"; +import { logger } from "../../../../logger"; +import { FirebaseError } from "../../../../error"; + +export interface PackageJSON { + dependencies?: Record; + devDependencies?: Record; + scripts?: Record; + engines?: Record; +} + +const NODE_LATEST_BASE_IMAGE = "node:18-slim"; +const NODE_RUNTIME_ID = "nodejs"; +const PACKAGE_JSON = "package.json"; +const PACKAGE_LOCK_JSON = "package-lock.json"; +const YARN = "yarn"; +const YARN_LOCK = "yarn.lock"; +const NPM = "npm"; + +export class NodejsRuntime implements Runtime { + private readonly runtimeRequiredFiles: string[] = [PACKAGE_JSON]; + private readonly contentCache: Record = {}; + + // Checks if the codebase is using Node as runtime. + async match(fs: FileSystem): Promise { + const areAllFilesPresent = await Promise.all( + this.runtimeRequiredFiles.map((file) => fs.exists(file)) + ); + + return Promise.resolve(areAllFilesPresent.every((present) => present)); + } + + getRuntimeName(): string { + return NODE_RUNTIME_ID; + } + + getNodeImage(version: Record | undefined): string { + // If no version is mentioned explicitly, assuming application is compatible with latest version. + if (!version) { + return NODE_LATEST_BASE_IMAGE; + } + const nodeVersion = version.node; + // Splits version number `>=18..0.5` to `>=` and `18.0.5` + const versionPattern = /^([>=<^~]+)?(\d+\.\d+\.\d+)$/; + const versionMatch = versionPattern.exec(nodeVersion); + if (!versionMatch) { + return NODE_LATEST_BASE_IMAGE; + } + const operator = versionMatch[1]; + const versionNumber = versionMatch[2]; + const majorVersion = parseInt(versionNumber.split(".")[0]); + if ((!operator || operator === "^" || operator === "~") && majorVersion < 18) { + throw new FirebaseError( + "Unsupported node version number, only versions >= 18 are supported." + ); + } + + return NODE_LATEST_BASE_IMAGE; + } + + async getPackageManager(fs: FileSystem): Promise { + if (await fs.exists(YARN_LOCK)) { + return YARN; + } + + return NPM; + } + + async getDependenciesForNPM( + fs: FileSystem, + packageJSON: PackageJSON + ): Promise> { + const directDependencies = { ...packageJSON.dependencies, ...packageJSON.devDependencies }; + let transitiveDependencies = {}; + + const packageLockJSONRaw = await readOrNull(fs, PACKAGE_LOCK_JSON); + if (!packageLockJSONRaw) { + return directDependencies; + } + const packageLockJSON = JSON.parse(packageLockJSONRaw); + const directDependencyNames = Object.keys(directDependencies).map((x) => + join("node_modules", x) + ); + + directDependencyNames.forEach((directDepName) => { + const transitiveDeps = packageLockJSON.packages[directDepName].dependencies; + transitiveDependencies = { ...transitiveDependencies, ...transitiveDeps }; + }); + + return { ...directDependencies, ...transitiveDependencies }; + } + + async getDependencies( + fs: FileSystem, + packageJSON: PackageJSON, + packageManager: string + ): Promise> { + try { + let dependencies = {}; + if (packageManager === NPM) { + dependencies = await this.getDependenciesForNPM(fs, packageJSON); + } + + return dependencies; + } catch (error: any) { + logger.error("Error while reading dependencies for the project: ", error); + throw error; + } + } + + packageManagerInstallCommand(packageManager: string): string | undefined { + const packages: string[] = []; + if (packageManager === "yarn") { + packages.push("yarn"); + } + if (!packages.length) { + return undefined; + } + + return `npm install --global ${packages.join(" ")}`; + } + + installCommand(packageManager: string): string | undefined { + if (packageManager === "npm") { + return "npm ci"; + } else if (packageManager === "yarn") { + return "yarn install"; + } + + return undefined; + } + + detectedCommands( + packageManager: string, + scripts: Record | undefined, + matchedFramework: FrameworkSpec | null + ): LifecycleCommands { + const commands: LifecycleCommands = { + build: this.getBuildCommand(packageManager, scripts, matchedFramework), + dev: this.getDevCommand(packageManager, scripts, matchedFramework), + run: this.getRunCommand(packageManager, scripts, matchedFramework), + }; + + return commands; + } + + // Converts the prefix of command to required packageManager. + // Ex: If packageManager is 'yarn' then converts `npm run build` to `yarn run build`. + replaceCommandPrefixWithPackageManager(command: Command, packageManager: string): Command { + if (command.cmd !== "") { + if (Array.isArray(command.cmd)) { + command.cmd.map((currCmd) => currCmd.replace(/^\S+/, packageManager)); + } else { + command.cmd.replace(/^\S+/, packageManager); + } + } + + return command; + } + + getBuildCommand( + packageManager: string, + scripts: Record | undefined, + matchedFramework: FrameworkSpec | null + ): Command { + let buildCommand: Command = { cmd: "" }; + if (scripts?.build) { + buildCommand.cmd = scripts.build; + } else if (matchedFramework && matchedFramework.commands?.build) { + buildCommand = matchedFramework.commands.build; + } + buildCommand = this.replaceCommandPrefixWithPackageManager(buildCommand, packageManager); + + return buildCommand; + } + + getDevCommand( + packageManager: string, + scripts: Record | undefined, + matchedFramework: FrameworkSpec | null + ): Command { + let devCommand: Command = { cmd: "", env: { NODE_ENV: "dev" } }; + if (scripts?.dev) { + devCommand.cmd = scripts.dev; + } else if (matchedFramework && matchedFramework.commands?.dev) { + devCommand = matchedFramework.commands.dev; + } + devCommand = this.replaceCommandPrefixWithPackageManager(devCommand, packageManager); + + return devCommand; + } + + getRunCommand( + packageManager: string, + scripts: Record | undefined, + matchedFramework: FrameworkSpec | null + ): Command { + let runCommand: Command = { cmd: "", env: { NODE_ENV: "production" } }; + if (scripts?.start) { + runCommand.cmd = scripts.start; + } else if (matchedFramework && matchedFramework.commands?.run) { + runCommand = matchedFramework.commands.run; + } + runCommand = this.replaceCommandPrefixWithPackageManager(runCommand, packageManager); + + return runCommand; + } + + async analyseCodebase( + fs: FileSystem, + allFrameworkSpecs: FrameworkSpec[] + ): Promise { + try { + const packageJSONRaw = await readOrNull(fs, PACKAGE_JSON); + if (!packageJSONRaw) { + return null; + } + const packageJSON = JSON.parse(packageJSONRaw) as PackageJSON; + const packageManager = await this.getPackageManager(fs); + const nodeImage = this.getNodeImage(packageJSON.engines); + const dependencies = await this.getDependencies(fs, packageJSON, packageManager); + const matchedFramework = await frameworkMatcher( + NODE_RUNTIME_ID, + fs, + allFrameworkSpecs, + dependencies + ); + + const runtimeSpec: RuntimeSpec = { + id: NODE_RUNTIME_ID, + baseImage: nodeImage, + packageManagerInstallCommand: this.packageManagerInstallCommand(packageManager), + installCommand: this.installCommand(packageManager), + detectedCommands: this.detectedCommands( + packageManager, + packageJSON.scripts, + matchedFramework + ), + }; + + return runtimeSpec; + } catch (error: any) { + throw new FirebaseError(`Failed to indentify commands for codebase: ${error}`); + } + } +} diff --git a/src/frameworks/compose/discover/types.ts b/src/frameworks/compose/discover/types.ts new file mode 100644 index 00000000000..677e054ec27 --- /dev/null +++ b/src/frameworks/compose/discover/types.ts @@ -0,0 +1,80 @@ +export interface FileSystem { + exists(file: string): Promise; + read(file: string): Promise; +} + +export interface Runtime { + match(fs: FileSystem): Promise; + getRuntimeName(): string; + analyseCodebase(fs: FileSystem, allFrameworkSpecs: FrameworkSpec[]): Promise; +} + +export interface Command { + // Consider: string[] for series of commands that must execute successfully + // in sequence. + cmd: string; + + // Environment in which command is executed. + env?: Record; +} + +export interface LifecycleCommands { + build?: Command; + run?: Command; + dev?: Command; +} + +export interface FrameworkSpec { + id: string; + + // Only analyze Frameworks with a runtime that matches the matched runtime + runtime: string; + + // e.g. nextjs. Used to verify that Web Frameworks' legacy code and the + // FrameworkSpec agree with one another + webFrameworkId?: string; + + // List of dependencies that should be present in the project. + requiredDependencies: Array<{ + name: string; + // Version + semver?: string; + }>; + + // If a requiredFiles is an array, then one of the files in the array must match. + // This supports, for example, a file that can be a js, ts, or mjs file. + requiredFiles?: Array; + + // Any commands that this framework needs that are not standard for the + // runtime. Often times, this can be empty (e.g. depend on npm run build and + // npm run start) + commands?: LifecycleCommands; + + // We must resolve to a single framework when getting build/dev/run commands. + // embedsFrameworks helps decide tiebreakers by saying, for example, that "astro" + // can embed "svelte", so if both frameworks are discovered, monospace can + // suggest both frameworks' plugins, but should run astro's commands. + embedsFrameworks?: string[]; +} + +export interface RuntimeSpec { + // e.g. `nodejs` + id: string; + + // e.g. `node18-slim`. Depends on user code (e.g. engine field in package.json) + baseImage: string; + + // e.g. `npm install yarn typescript` + packageManagerInstallCommand?: string; + + // e.g. `npm ci`, `npm install`, `yarn` + installCommand?: string; + + // Commands to run right before exporting the container image + // e.g. npm prune --omit=dev, yarn install --production=true + exportCommands?: string[]; + + // The runtime has detected a command that should always be run irrespective of + // the framework (e.g. the "build" script always wins in Node) + detectedCommands?: LifecycleCommands; +} diff --git a/src/test/frameworks/compose/discover/filesystem.spec.ts b/src/test/frameworks/compose/discover/filesystem.spec.ts new file mode 100644 index 00000000000..db1212b3393 --- /dev/null +++ b/src/test/frameworks/compose/discover/filesystem.spec.ts @@ -0,0 +1,56 @@ +import { MockFileSystem } from "./mockFileSystem"; +import { expect } from "chai"; + +describe("MockFileSystem", () => { + let fileSystem: MockFileSystem; + + before(() => { + fileSystem = new MockFileSystem({ + "package.json": JSON.stringify({ + name: "expressapp", + version: "1.0.0", + scripts: { + test: 'echo "Error: no test specified" && exit 1', + }, + dependencies: { + express: "^4.18.2", + }, + }), + }); + }); + + describe("exists", () => { + it("should return true if file exists in the directory ", async () => { + const fileExists = await fileSystem.exists("package.json"); + + expect(fileExists).to.be.true; + expect(fileSystem.getExistsCache("package.json")).to.be.true; + }); + + it("should return false if file does not exist in the directory", async () => { + const fileExists = await fileSystem.exists("nonexistent.txt"); + + expect(fileExists).to.be.false; + }); + }); + + describe("read", () => { + it("should read and return the contents of the file", async () => { + const fileContent = await fileSystem.read("package.json"); + + const expected = JSON.stringify({ + name: "expressapp", + version: "1.0.0", + scripts: { + test: 'echo "Error: no test specified" && exit 1', + }, + dependencies: { + express: "^4.18.2", + }, + }); + + expect(fileContent).to.equal(expected); + expect(fileSystem.getContentCache("package.json")).to.equal(expected); + }); + }); +}); diff --git a/src/test/frameworks/compose/discover/frameworkMatcher.spec.ts b/src/test/frameworks/compose/discover/frameworkMatcher.spec.ts new file mode 100644 index 00000000000..bb80b326fe6 --- /dev/null +++ b/src/test/frameworks/compose/discover/frameworkMatcher.spec.ts @@ -0,0 +1,171 @@ +import { MockFileSystem } from "./mockFileSystem"; +import { expect } from "chai"; +import { + frameworkMatcher, + removeEmbededFrameworks, + filterFrameworksWithFiles, + filterFrameworksWithDependencies, +} from "../../../../frameworks/compose/discover/frameworkMatcher"; +import { frameworkSpecs } from "../../../../frameworks/compose/discover/frameworkSpec"; +import { FrameworkSpec } from "../../../../frameworks/compose/discover/types"; + +describe("frameworkMatcher", () => { + let fileSystem: MockFileSystem; + const NODE_ID = "nodejs"; + + before(() => { + fileSystem = new MockFileSystem({ + "package.json": JSON.stringify({ + name: "expressapp", + version: "1.0.0", + scripts: { + test: 'echo "Error: no test specified" && exit 1', + }, + dependencies: { + express: "^4.18.2", + }, + }), + "package-lock.json": "Unused: contents of package-lock file", + }); + }); + + describe("frameworkMatcher", () => { + it("should return express FrameworkSpec after analysing express application", async () => { + const expressDependency: Record = { + express: "^4.18.2", + }; + const matchedFramework = await frameworkMatcher( + NODE_ID, + fileSystem, + frameworkSpecs, + expressDependency + ); + const expressFrameworkSpec: FrameworkSpec = { + id: "express", + runtime: "nodejs", + webFrameworkId: "Express.js", + requiredDependencies: [ + { + name: "express", + }, + ], + }; + + expect(matchedFramework).to.deep.equal(expressFrameworkSpec); + }); + }); + + describe("removeEmbededFrameworks", () => { + it("should return frameworks after removing embeded frameworks", () => { + const allFrameworks: FrameworkSpec[] = [ + { + id: "express", + runtime: "nodejs", + requiredDependencies: [], + }, + { + id: "next", + runtime: "nodejs", + requiredDependencies: [], + embedsFrameworks: ["react"], + }, + { + id: "react", + runtime: "nodejs", + requiredDependencies: [], + }, + ]; + const actual = removeEmbededFrameworks(allFrameworks); + const expected: FrameworkSpec[] = [ + { + id: "express", + runtime: "nodejs", + requiredDependencies: [], + }, + { + id: "next", + runtime: "nodejs", + requiredDependencies: [], + embedsFrameworks: ["react"], + }, + ]; + + expect(actual).to.have.deep.members(expected); + expect(actual).to.have.length(2); + }); + }); + + describe("filterFrameworksWithFiles", () => { + it("should return frameworks having all the required files", async () => { + const allFrameworks: FrameworkSpec[] = [ + { + id: "express", + runtime: "nodejs", + requiredDependencies: [], + requiredFiles: [["package.json", "package-lock.json"]], + }, + { + id: "next", + runtime: "nodejs", + requiredDependencies: [], + requiredFiles: [["next.config.js"], "next.config.ts"], + }, + ]; + const actual = await filterFrameworksWithFiles(allFrameworks, fileSystem); + const expected: FrameworkSpec[] = [ + { + id: "express", + runtime: "nodejs", + requiredDependencies: [], + requiredFiles: [["package.json", "package-lock.json"]], + }, + ]; + + expect(actual).to.have.deep.members(expected); + expect(actual).to.have.length(1); + }); + }); + + describe("filterFrameworksWithDependencies", () => { + it("should return frameworks having required dependencies with in the project dependencies", () => { + const allFrameworks: FrameworkSpec[] = [ + { + id: "express", + runtime: "nodejs", + requiredDependencies: [ + { + name: "express", + }, + ], + }, + { + id: "next", + runtime: "nodejs", + requiredDependencies: [ + { + name: "next", + }, + ], + }, + ]; + const projectDependencies: Record = { + express: "^4.18.2", + }; + const actual = filterFrameworksWithDependencies(allFrameworks, projectDependencies); + const expected: FrameworkSpec[] = [ + { + id: "express", + runtime: "nodejs", + requiredDependencies: [ + { + name: "express", + }, + ], + }, + ]; + + expect(actual).to.have.deep.members(expected); + expect(actual).to.have.length(1); + }); + }); +}); diff --git a/src/test/frameworks/compose/discover/mockFileSystem.ts b/src/test/frameworks/compose/discover/mockFileSystem.ts new file mode 100644 index 00000000000..cdbf21d6159 --- /dev/null +++ b/src/test/frameworks/compose/discover/mockFileSystem.ts @@ -0,0 +1,38 @@ +import { FileSystem } from "../../../../frameworks/compose/discover/types"; + +export class MockFileSystem implements FileSystem { + private readonly existsCache: Record = {}; + private readonly contentCache: Record = {}; + + constructor(private readonly fileSys: Record) {} + + exists(path: string): Promise { + if (!(path in this.existsCache)) { + this.existsCache[path] = path in this.fileSys; + } + + return Promise.resolve(this.existsCache[path]); + } + + read(path: string): Promise { + if (!(path in this.contentCache)) { + if (!(path in this.fileSys)) { + const err = new Error("File path not found"); + err.cause = "ENOENT"; + throw err; + } else { + this.contentCache[path] = this.fileSys[path]; + } + } + + return Promise.resolve(this.contentCache[path]); + } + + getContentCache(path: string): string { + return this.contentCache[path]; + } + + getExistsCache(path: string): boolean { + return this.existsCache[path]; + } +} diff --git a/src/test/frameworks/compose/discover/runtime/node.spec.ts b/src/test/frameworks/compose/discover/runtime/node.spec.ts new file mode 100644 index 00000000000..e2ed74f9b24 --- /dev/null +++ b/src/test/frameworks/compose/discover/runtime/node.spec.ts @@ -0,0 +1,240 @@ +import { MockFileSystem } from "../mockFileSystem"; +import { expect } from "chai"; +import { + NodejsRuntime, + PackageJSON, +} from "../../../../../frameworks/compose/discover/runtime/node"; +import { FrameworkSpec } from "../../../../../frameworks/compose/discover/types"; + +describe("NodejsRuntime", () => { + let nodeJSRuntime: NodejsRuntime; + + before(() => { + nodeJSRuntime = new NodejsRuntime(); + }); + + describe("getNodeImage", () => { + it("should return a valid node Image", () => { + const version: Record = { + node: ">=18.5.4", + }; + const actualImage = nodeJSRuntime.getNodeImage(version); + const expectedImage = "node:18-slim"; + + expect(actualImage).to.deep.equal(expectedImage); + }); + + it("should return node Image", () => { + const version: Record = { + node: "^18.0.2", + }; + const actualImage = nodeJSRuntime.getNodeImage(version); + const expectedImage = "node:18-slim"; + + expect(actualImage).to.deep.equal(expectedImage); + }); + + it("should return latest node Image", () => { + const version: Record = { + node: "18.8.2", + }; + const actualImage = nodeJSRuntime.getNodeImage(version); + const expectedImage = "node:18-slim"; + + expect(actualImage).to.deep.equal(expectedImage); + }); + }); + + describe("getPackageManager", () => { + it("should return yarn package manager", async () => { + const fileSystem = new MockFileSystem({ + "yarn.lock": "It is test file", + }); + const actual = await nodeJSRuntime.getPackageManager(fileSystem); + const expected = "yarn"; + + expect(actual).to.equal(expected); + }); + }); + + describe("getDependencies", () => { + it("should return direct and transitive dependencies", async () => { + const fileSystem = new MockFileSystem({ + "package.json": JSON.stringify({ + dependencies: { + express: "^4.18.2", + }, + devDependencies: { + nodemon: "^2.0.12", + mocha: "^9.1.1", + }, + }), + "package-lock.json": JSON.stringify({ + packages: { + "node_modules/express": { + dependencies: { + accepts: "~1.3.8", + "array-flatten": "1.1.1", + }, + }, + "node_modules/nodemon": { + dependencies: { + chokidar: "^3.5.2", + debug: "^3.2.7", + }, + }, + "node_modules/mocha": { + dependencies: { + "escape-string-regexp": "4.0.0", + "find-up": "5.0.0", + }, + }, + }, + }), + }); + const packageJSON: PackageJSON = { + dependencies: { + express: "^4.18.2", + }, + devDependencies: { + nodemon: "^2.0.12", + mocha: "^9.1.1", + }, + }; + const actual = await nodeJSRuntime.getDependencies(fileSystem, packageJSON, "npm"); + const expected = { + express: "^4.18.2", + nodemon: "^2.0.12", + mocha: "^9.1.1", + accepts: "~1.3.8", + "array-flatten": "1.1.1", + chokidar: "^3.5.2", + debug: "^3.2.7", + "escape-string-regexp": "4.0.0", + "find-up": "5.0.0", + }; + + expect(actual).to.deep.equal(expected); + }); + }); + + describe("detectedCommands", () => { + it("should commands required to run the app.", () => { + const matchedFramework: FrameworkSpec = { + id: "next", + runtime: "nodejs", + requiredDependencies: [], + commands: { + dev: { + cmd: "next dev", + env: { NODE_ENV: "dev" }, + }, + }, + }; + + const scripts = { + build: "next build", + start: "next start", + }; + + const actual = nodeJSRuntime.detectedCommands("npm", scripts, matchedFramework); + const expected = { + build: { + cmd: "next build", + }, + dev: { + cmd: "next dev", + env: { NODE_ENV: "dev" }, + }, + run: { + cmd: "next start", + env: { NODE_ENV: "production" }, + }, + }; + + expect(actual).to.deep.equal(expected); + }); + }); + + describe("analyseCodebase", () => { + it("should return runtime specs", async () => { + const fileSystem = new MockFileSystem({ + "next.config.js": "For testing", + "next.config.ts": "For testing", + "package.json": JSON.stringify({ + scripts: { + build: "next build", + start: "next start", + }, + dependencies: { + next: "13.4.5", + react: "18.2.0", + }, + engines: { + node: ">=18.5.2", + }, + }), + "package-lock.json": JSON.stringify({ + packages: { + "node_modules/next": { + dependencies: { + accepts: "~1.3.8", + "array-flatten": "1.1.1", + }, + }, + "node_modules/react": { + dependencies: { + chokidar: "^3.5.2", + debug: "^3.2.7", + }, + }, + }, + }), + }); + + const allFrameworks: FrameworkSpec[] = [ + { + id: "express", + runtime: "nodejs", + requiredDependencies: [{ name: "express" }], + }, + { + id: "next", + runtime: "nodejs", + requiredDependencies: [{ name: "next" }], + requiredFiles: [["next.config.js"], "next.config.ts"], + embedsFrameworks: ["react"], + commands: { + dev: { + cmd: "next dev", + env: { NODE_ENV: "dev" }, + }, + }, + }, + ]; + + const actual = await nodeJSRuntime.analyseCodebase(fileSystem, allFrameworks); + const expected = { + id: "nodejs", + baseImage: "node:18-slim", + packageManagerInstallCommand: undefined, + installCommand: "npm ci", + detectedCommands: { + build: { + cmd: "next build", + }, + dev: { + cmd: "next dev", + env: { NODE_ENV: "dev" }, + }, + run: { + cmd: "next start", + env: { NODE_ENV: "production" }, + }, + }, + }; + + expect(actual).to.deep.equal(expected); + }); + }); +});