From 26a8c52d20b535aa59bd2f2113feca61d3fbe910 Mon Sep 17 00:00:00 2001 From: Dirk de Visser Date: Wed, 6 Sep 2023 12:49:17 +0200 Subject: [PATCH] chore(compas): add cli test setup - Handles persisted directories in between test runs. Should improve debuggability icm with the `--debug` flag - Supports sending inputs - Collects the resulting stdout to match on. - Feels somewhat overkill to snapshot output + directory state for now. May do that at some point. --- packages/compas/src/main/development/state.js | 6 +- packages/compas/src/main/init/compas.js | 15 +- packages/compas/src/shared/package-manager.js | 11 +- packages/compas/test/cli.test.js | 73 ++++++ packages/compas/test/commands/init.test.js | 242 ++++++++++++++++++ .../compas/test/development/cache.test.js | 176 +++++++++++++ packages/compas/test/utils.js | 145 +++++++++++ 7 files changed, 658 insertions(+), 10 deletions(-) create mode 100644 packages/compas/test/cli.test.js create mode 100644 packages/compas/test/commands/init.test.js create mode 100644 packages/compas/test/development/cache.test.js create mode 100644 packages/compas/test/utils.js diff --git a/packages/compas/src/main/development/state.js b/packages/compas/src/main/development/state.js index 6a33658f4d..7cde4e53b3 100644 --- a/packages/compas/src/main/development/state.js +++ b/packages/compas/src/main/development/state.js @@ -110,14 +110,10 @@ export class State { new ActionsIntegration(this), new PackageManagerIntegration(this), - // Should be the last integration + // Should be the last integration, since it will new FileWatcherIntegration(this), ]; - // TODO: Package install command - - // TODO: package install - // Init and add to state for (const integration of integrations) { await integration.init(); diff --git a/packages/compas/src/main/init/compas.js b/packages/compas/src/main/init/compas.js index f87c242e5b..f75eb08ab5 100644 --- a/packages/compas/src/main/init/compas.js +++ b/packages/compas/src/main/init/compas.js @@ -1,6 +1,6 @@ import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; -import { exec, spawn } from "@compas/stdlib"; +import { environment, exec, spawn } from "@compas/stdlib"; import { writeFileChecked } from "../../shared/fs.js"; import { logger } from "../../shared/output.js"; import { packageManagerDetermine } from "../../shared/package-manager.js"; @@ -84,7 +84,6 @@ async function initCompasInNewProject(env) { version: "0.0.1", type: "module", scripts: {}, - keywords: [], dependencies: { compas: compasVersion, }, @@ -136,7 +135,17 @@ coverage await exec("git init"); await exec("git checkout -b main"); await exec("git add -A"); - await exec(`git commit -m "Initialized project with ${env.compasVersion}"`); + + if (environment._COMPAS_SKIP_COMMIT_SIGN === "true") { + // Test purposes + await exec( + `git commit -c commit.gpgsign=false -m "Initialized project with ${env.compasVersion}"`, + ); + } else { + await exec( + `git commit -m "Initialized project with ${env.compasVersion}"`, + ); + } } logger.info(` diff --git a/packages/compas/src/shared/package-manager.js b/packages/compas/src/shared/package-manager.js index a2191e09c6..de70bcaf8f 100644 --- a/packages/compas/src/shared/package-manager.js +++ b/packages/compas/src/shared/package-manager.js @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { pathJoin } from "@compas/stdlib"; +import { environment, pathJoin } from "@compas/stdlib"; export const PACKAGE_MANAGER_LOCK_FILES = [ "bun.lockb", @@ -21,7 +21,14 @@ export const PACKAGE_MANAGER_LOCK_FILES = [ * }} */ export function packageManagerDetermine(rootDirectory = "") { - if (existsSync(pathJoin(rootDirectory, "bun.lockb"))) { + if (environment._COMPAS_SKIP_PACKAGE_MANAGER === "true") { + return { + name: "_compas_skip_package_manager", + installCommand: "echo '_compas_skip_package_manager_install'", + nodeModulesBinCommand: "echo '_compas_skip_package_manager_bin'", + packageJsonScriptCommand: "echo '_compas_skip_package_manager_script'", + }; + } else if (existsSync(pathJoin(rootDirectory, "bun.lockb"))) { return { name: "bun", installCommand: "bun install", diff --git a/packages/compas/test/cli.test.js b/packages/compas/test/cli.test.js new file mode 100644 index 0000000000..d2c37e56ad --- /dev/null +++ b/packages/compas/test/cli.test.js @@ -0,0 +1,73 @@ +import { existsSync } from "node:fs"; +import { readdir } from "node:fs/promises"; +import { mainTestFn, test } from "@compas/cli"; +import { pathJoin } from "@compas/stdlib"; +import { testCompasCli, testDirectory } from "./utils.js"; + +mainTestFn(import.meta); + +test("compas/cli", (t) => { + t.jobs = 4; + + const workingDirectory = testDirectory(t.name); + + t.test("does not create a debug file without --debug", async (t) => { + const cwd = workingDirectory("no-debug"); + + await testCompasCli({ + args: ["foo"], + inputs: [], + waitForExit: true, + cwd, + }); + + t.equal(existsSync(pathJoin(cwd, ".cache/compas")), false); + }); + + t.test("creates a debug file with --debug", async (t) => { + const cwd = workingDirectory("with-debug"); + + await testCompasCli({ + args: ["foo", "--debug"], + inputs: [], + waitForExit: true, + cwd, + }); + + t.equal(existsSync(pathJoin(cwd, ".cache/compas")), true); + t.equal( + (await readdir(pathJoin(cwd, ".cache/compas"), {})).some( + (it) => it.startsWith("debug-") && it.endsWith(".txt"), + ), + true, + ); + }); + + t.test("package.json is not available", async (t) => { + const cwd = workingDirectory("no-package-json"); + + const { stdout } = await testCompasCli({ + args: [], + inputs: [], + waitForExit: true, + cwd, + }); + + t.ok( + stdout.includes("Please run 'npx compas@latest init' to install Compas."), + ); + }); + + t.test("unsupported command", async (t) => { + const cwd = workingDirectory("unknown-command"); + + const { stdout } = await testCompasCli({ + args: ["foo"], + inputs: [], + waitForExit: true, + cwd, + }); + + t.ok(stdout.includes(`Unsupported command. Available commands:`)); + }); +}); diff --git a/packages/compas/test/commands/init.test.js b/packages/compas/test/commands/init.test.js new file mode 100644 index 0000000000..7b61feedd3 --- /dev/null +++ b/packages/compas/test/commands/init.test.js @@ -0,0 +1,242 @@ +import { existsSync } from "node:fs"; +import { mkdir, readdir, readFile } from "node:fs/promises"; +import { mainTestFn, test } from "@compas/cli"; +import { dirnameForModule, pathJoin } from "@compas/stdlib"; +import { writeFileChecked } from "../../src/shared/fs.js"; +import { testCompasCli, testDirectory } from "../utils.js"; + +mainTestFn(import.meta); + +test("compas/commands/init", (t) => { + t.jobs = 4; + + const workingDirectory = testDirectory(t.name); + + t.test("exits in CI mode", async (t) => { + const cwd = workingDirectory("no-ci"); + + const { stdout } = await testCompasCli({ + args: ["init"], + inputs: [], + waitForExit: true, + cwd, + env: { + ...process.env, + CI: "true", + }, + }); + + t.ok(stdout.includes("'compas init' is not supported in CI.")); + }); + + t.test("new project", async (t) => { + const cwd = workingDirectory("new-project"); + + const { stdout } = await testCompasCli({ + args: ["init"], + inputs: [], + waitForExit: true, + cwd, + }); + + // Package.json + t.ok( + existsSync(pathJoin(cwd, "package.json")), + "Should create a package.json", + ); + t.deepEqual( + JSON.parse(await readFile(pathJoin(cwd, "package.json"), "utf-8")), + { + name: "new-project", + private: true, + version: "0.0.1", + type: "module", + scripts: {}, + dependencies: { + compas: JSON.parse( + await readFile( + pathJoin(dirnameForModule(import.meta), "../../package.json"), + "utf-8", + ), + ).version, + }, + }, + ); + + // Package manager + t.ok(stdout.includes("_compas_skip_package_manager_install")); + + // Git + t.ok( + existsSync(pathJoin(cwd, ".gitignore")), + ".gitignore should've been created", + ); + t.ok(existsSync(pathJoin(cwd, ".git")), "Git repo should've been created"); + + // Output + t.ok(stdout.includes("'npx compas'")); + }); + + t.test("new project - already .git", async (t) => { + const cwd = workingDirectory("new-project-already-git"); + + await mkdir(pathJoin(cwd, ".git")); + + const { stdout } = await testCompasCli({ + args: ["init"], + inputs: [], + waitForExit: true, + cwd, + }); + + // Package.json + t.ok( + existsSync(pathJoin(cwd, "package.json")), + "Should create a package.json", + ); + + // Package manager + t.ok(stdout.includes("_compas_skip_package_manager_install")); + + // Git + t.ok( + existsSync(pathJoin(cwd, ".gitignore")), + ".gitignore should've been created", + ); + t.equal( + (await readdir(pathJoin(cwd, ".git"))).length, + 0, + ".git directory should be empty", + ); + + // Output + t.ok(stdout.includes("'npx compas'")); + }); + + t.test("exiting project - no dependencies", async (t) => { + const cwd = workingDirectory("existing-project-no-deps"); + + await writeFileChecked(pathJoin(cwd, "package.json"), "{}"); + + const { stdout } = await testCompasCli({ + args: ["init"], + inputs: [], + waitForExit: true, + cwd, + }); + + // Package.json + + t.deepEqual( + JSON.parse(await readFile(pathJoin(cwd, "package.json"), "utf-8")), + { + dependencies: { + compas: JSON.parse( + await readFile( + pathJoin(dirnameForModule(import.meta), "../../package.json"), + "utf-8", + ), + ).version, + }, + }, + ); + + // Package manager + t.ok(stdout.includes("Patching package.json")); + t.ok(stdout.includes("_compas_skip_package_manager_install")); + + // Output + t.ok(stdout.includes("Ready to roll!")); + }); + + t.test("exiting project - no update", async (t) => { + const cwd = workingDirectory("existing-project-no-update"); + + await writeFileChecked( + pathJoin(cwd, "package.json"), + JSON.stringify({ + dependencies: { + compas: JSON.parse( + await readFile( + pathJoin(dirnameForModule(import.meta), "../../package.json"), + "utf-8", + ), + ).version, + }, + }), + ); + + const { stdout } = await testCompasCli({ + args: ["init"], + inputs: [], + waitForExit: true, + cwd, + }); + + // Package.json + t.deepEqual( + JSON.parse(await readFile(pathJoin(cwd, "package.json"), "utf-8")), + { + dependencies: { + compas: JSON.parse( + await readFile( + pathJoin(dirnameForModule(import.meta), "../../package.json"), + "utf-8", + ), + ).version, + }, + }, + ); + + // Package manager + t.ok( + !stdout.includes("_compas_skip_package_manager_install"), + "Package manager did run, but didn't need to", + ); + + // Output + t.ok(stdout.includes("Already up-to-date!")); + }); + + t.test("exiting project - update", async (t) => { + const cwd = workingDirectory("existing-project-update"); + + await writeFileChecked( + pathJoin(cwd, "package.json"), + JSON.stringify({ + dependencies: { + compas: "*", + }, + }), + ); + + const { stdout } = await testCompasCli({ + args: ["init"], + inputs: [], + waitForExit: true, + cwd, + }); + + // Package.json + t.deepEqual( + JSON.parse(await readFile(pathJoin(cwd, "package.json"), "utf-8")), + { + dependencies: { + compas: JSON.parse( + await readFile( + pathJoin(dirnameForModule(import.meta), "../../package.json"), + "utf-8", + ), + ).version, + }, + }, + ); + + // Package manager + t.ok(stdout.includes("Patching package.json")); + t.ok(stdout.includes("_compas_skip_package_manager_install")); + + // Output + t.ok(stdout.includes("Ready to roll!")); + }); +}); diff --git a/packages/compas/test/development/cache.test.js b/packages/compas/test/development/cache.test.js new file mode 100644 index 0000000000..a6f6c847be --- /dev/null +++ b/packages/compas/test/development/cache.test.js @@ -0,0 +1,176 @@ +import { readFile } from "node:fs/promises"; +import { mainTestFn, test } from "@compas/cli"; +import { dirnameForModule, pathJoin } from "@compas/stdlib"; +import { writeFileChecked } from "../../src/shared/fs.js"; +import { testCompasCli, testDirectory } from "../utils.js"; + +mainTestFn(import.meta); + +test("compas/development/cache", (t) => { + t.jobs = 4; + + const workingDirectory = testDirectory(t.name); + + t.test("cache does not exist", async (t) => { + const cwd = workingDirectory("no-cache"); + + await writeFileChecked(pathJoin(cwd, "package.json"), "{}"); + + const { stdout } = await testCompasCli({ + args: [], + inputs: [ + { + write: "Q", + }, + ], + cwd, + }); + + t.ok(stdout.includes("Starting up...")); + }); + + t.test("cache version mismatch", async (t) => { + const cwd = workingDirectory("version-mismatch"); + + await writeFileChecked(pathJoin(cwd, "package.json"), "{}"); + await writeFileChecked( + pathJoin(cwd, ".cache/compas/cache.json"), + JSON.stringify({ + version: "0.5.0", + }), + ); + + const { stdout } = await testCompasCli({ + args: [], + inputs: [ + { + write: "Q", + }, + ], + cwd, + }); + + t.ok(stdout.includes("Starting up...")); + }); + + t.test("cache not parseable", async (t) => { + const cwd = workingDirectory("no-parseable"); + + await writeFileChecked(pathJoin(cwd, "package.json"), "{}"); + await writeFileChecked( + pathJoin(cwd, ".cache/compas/cache.json"), + JSON.stringify({ + version: "0.5.0", + }).slice(0, 5), + ); + + const { stdout } = await testCompasCli({ + args: [], + inputs: [ + { + write: "Q", + }, + ], + cwd, + }); + + t.ok(stdout.includes("Starting up...")); + }); + + t.test("cache not passing validators", async (t) => { + const cwd = workingDirectory("validators"); + + await writeFileChecked(pathJoin(cwd, "package.json"), "{}"); + await writeFileChecked( + pathJoin(cwd, ".cache/compas/cache.json"), + JSON.stringify({ + version: 1, + }), + ); + + const { stdout } = await testCompasCli({ + args: [], + inputs: [ + { + write: "Q", + }, + ], + cwd, + }); + + t.ok(stdout.includes("Starting up...")); + }); + + t.test("cache is used", async (t) => { + const cwd = workingDirectory("valid"); + + await writeFileChecked(pathJoin(cwd, "package.json"), "{}"); + await writeFileChecked( + pathJoin(cwd, ".cache/compas/cache.json"), + JSON.stringify({ + version: `Compas v${ + JSON.parse( + await readFile( + pathJoin(dirnameForModule(import.meta), "../../package.json"), + "utf-8", + ), + ).version + }`, + }), + ); + + const { stdout } = await testCompasCli({ + args: ["--debug"], + inputs: [ + { + write: "Q", + }, + ], + cwd, + }); + + t.ok(stdout.includes("Starting up from cache...")); + }); + + t.test("cache is written", async (t) => { + const cwd = workingDirectory("valid"); + + await writeFileChecked(pathJoin(cwd, "package.json"), "{}"); + await writeFileChecked( + pathJoin(cwd, ".cache/compas/cache.json"), + JSON.stringify({ + version: `Compas v${ + JSON.parse( + await readFile( + pathJoin(dirnameForModule(import.meta), "../../package.json"), + "utf-8", + ), + ).version + }`, + }), + ); + + const { stdout } = await testCompasCli({ + args: ["--debug"], + inputs: [ + { + write: "1", + // Should wait for cache to be written. + timeout: 55, + }, + { + write: "Q", + }, + ], + cwd, + }); + + const resolvedCache = JSON.parse( + await readFile(pathJoin(cwd, ".cache/compas/cache.json"), "utf-8"), + ); + + t.ok(stdout.includes("Starting up from cache...")); + t.ok(resolvedCache.config); + t.ok(resolvedCache.cachesCleaned); + }); +}); diff --git a/packages/compas/test/utils.js b/packages/compas/test/utils.js new file mode 100644 index 0000000000..5f07b56972 --- /dev/null +++ b/packages/compas/test/utils.js @@ -0,0 +1,145 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdirSync, rmSync } from "node:fs"; +import { setTimeout } from "node:timers/promises"; +import { pathJoin } from "@compas/stdlib"; + +/** + * + * @param {string} suite + * @returns {(subdir: string) => string} + */ +export function testDirectory(suite) { + const baseDir = pathJoin(".cache/test", suite); + + return (subdir) => { + const dir = pathJoin(baseDir, subdir); + + rmSync(dir, { force: true, recursive: true }); + mkdirSync(dir, { recursive: true }); + + return dir; + }; +} + +/** + * Util to test CLI execution. + * + * Launches the CLI directly and is able to send a list of inputs to it. It collects the + * stdout and stderr to assert on. + * + * By default exits after executing all actions. Provide {@link options.waitForClose} if + * you want to wait till the process exits automatically. + * + * @param {{ + * args?: string[], + * waitForExit?: boolean, + * inputs: { + * write: string|Buffer, + * timeout?: number, + * }[] + * } & import("child_process").SpawnOptionsWithoutStdio} options + * @returns {Promise<{stdout: string, stderr: string}>} + */ +export async function testCompasCli({ + args, + inputs, + waitForExit, + ...spawnOpts +}) { + const cp = spawn( + `node`, + [ + pathJoin(process.cwd(), "./packages/compas/src/cli/bin.js"), + ...(args ?? ["--debug"]), + ], + { + ...spawnOpts, + stdio: ["pipe", "pipe", "pipe"], + env: { + ...(spawnOpts.env ?? { + ...process.env, + CI: "false", + APP_NAME: undefined, + _COMPAS_SKIP_PACKAGE_MANAGER: "true", + _COMPAS_SKIP_COMMIT_SIGN: "true", + }), + }, + }, + ); + + const exitHandler = () => { + cp.kill("SIGTERM"); + }; + process.once("exit", exitHandler); + + const stdoutBuffers = []; + const stderrBuffers = []; + + cp.stdout.on("data", (chunk) => { + stdoutBuffers.push(chunk); + }); + cp.stderr.on("data", (chunk) => { + stderrBuffers.push(chunk); + }); + + let closed = false; + cp.once("exit", () => { + closed = true; + }); + + // Wait max 500ms for the first output or process exit + for (let i = 0; i < 100; ++i) { + if (stdoutBuffers.length !== 0 || stderrBuffers.length !== 0 || closed) { + break; + } + + await setTimeout(5); + } + + if (closed) { + process.removeListener("exit", exitHandler); + return { + stdout: Buffer.concat(stdoutBuffers).toString(), + stderr: Buffer.concat(stderrBuffers).toString(), + }; + } + + for (const input of inputs) { + cp.stdin.write(input.write); + + await setTimeout(input.timeout ?? 0); + + if (closed) { + process.removeListener("exit", exitHandler); + + return { + stdout: Buffer.concat(stdoutBuffers).toString(), + stderr: Buffer.concat(stderrBuffers).toString(), + }; + } + } + + if (closed) { + process.removeListener("exit", exitHandler); + return { + stdout: Buffer.concat(stdoutBuffers).toString(), + stderr: Buffer.concat(stderrBuffers).toString(), + }; + } + + const p = once(cp, "exit"); + + if (!waitForExit) { + cp.kill("SIGTERM"); + } + + await p; + + process.removeListener("exit", exitHandler); + + return { + stdout: Buffer.concat(stdoutBuffers).toString(), + stderr: Buffer.concat(stderrBuffers).toString(), + }; +}