diff --git a/.gitignore b/.gitignore index d8dfd90..b907674 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /dist +/.test-dist public/ node_modules/ diff --git a/package.json b/package.json index 41fbf4b..03d1674 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,9 @@ "preversion": "npm run reset && npm test", "release": "npm version", "reset": "npm run clean && npm ci", - "test": "ts-node --project=test/tsconfig.json test/run-tests.ts" + "test": "npm run test:unit && npm run test:e2e", + "test:unit": "cd test && ts-node --script-mode ./run-tests.ts", + "test:e2e": "cd test && ts-node --script-mode ./run-e2e.ts" }, "devDependencies": { "@types/node": "~10", diff --git a/src/cli/commands/dev.ts b/src/cli/commands/dev.ts index 4401471..3de8984 100644 --- a/src/cli/commands/dev.ts +++ b/src/cli/commands/dev.ts @@ -182,8 +182,8 @@ const watchFolders = async ( // Try to load node-watch, falling back to fs watch if node-watch isn't // available. try { - const { default: watch } = await import("node-watch"); - watch(folders, { recursive: true }, listener); + const nodeWatch = await import("node-watch"); + nodeWatch.default(folders, { recursive: true }, listener); return; } catch (error) { if (error.code !== "MODULE_NOT_FOUND") { diff --git a/test/e2e/dev.tsx b/test/e2e/dev.tsx new file mode 100644 index 0000000..c10c732 --- /dev/null +++ b/test/e2e/dev.tsx @@ -0,0 +1,171 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { ChildProcess, spawn } from "child_process"; +import { promises as fs } from "fs"; +import * as path from "path"; + +import { testSuite } from "../lib"; + +const TEST_DIST_PATH = path.resolve(__dirname, "..", "..", ".test-dist"); + +const wait = async (timeMs: number): Promise => { + return new Promise((resolve) => { + setTimeout(() => resolve(), timeMs); + }); +}; + +const withTempDir = async ( + op: (tempDirPath: string) => Promise | void +): Promise => { + await fs.mkdir(TEST_DIST_PATH, { recursive: true }); + const tempDirPath = await fs.mkdtemp(`${TEST_DIST_PATH}/`); + try { + await op(tempDirPath); + } catch (error) { + throw new Error(`(${tempDirPath}): ${error}`); + } +}; + +const WEBSNACKS_REPO_ROOT = path.resolve(__dirname, "..", ".."); +const WEBSNACKS_BIN_PATH = path.join( + WEBSNACKS_REPO_ROOT, + "bin", + "websnacks.js" +); + +interface AsyncCommand { + complete: Promise; + process: ChildProcess; +} + +interface CliOptions { + cwd?: string; + timeoutMs?: number; +} + +const DEFAULT_CLI_OPTIONS = { + timeoutMs: 5_000, +}; + +const runCommand = ( + command: string, + args: string[] = [], + _options?: CliOptions +): AsyncCommand => { + const options = { ...DEFAULT_CLI_OPTIONS, ..._options }; + const process = spawn(command, args, { + ...options, + stdio: "pipe", + }); + const complete = new Promise((resolve, reject) => { + let threwError = false; + + let stdout = ""; + process.stdout.on("data", (data) => { + stdout += data.toString(); + }); + process.stderr.on("data", (data) => { + threwError = true; + process.kill(); + reject(new Error(`command output to stderr: ${data.toString()}`)); + }); + + const timer = setTimeout(() => { + threwError = true; + process.kill(); + reject(new Error(`max timeout of ${options.timeoutMs}ms reached`)); + }, options.timeoutMs); + process.on("exit", (code) => { + if (threwError) { + return; + } + clearTimeout(timer); + if (code !== null && code !== 0) { + reject(new Error(`command exited with non-zero code: ${code}`)); + return; + } + resolve(stdout); + }); + process.on("error", (error) => { + clearTimeout(timer); + if (!threwError) { + reject(new Error(`command errored: ${error}`)); + threwError = true; + } + }); + }); + return { + complete, + process, + }; +}; + +testSuite("dev command", ({ test, expect }) => { + test("starts without throwing error", async () => { + await withTempDir(async (tempDirPath) => { + await fs.writeFile( + path.join(tempDirPath, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + esModuleInterop: true, + module: "CommonJS", + moduleResolution: "node", + jsx: "react", + jsxFactory: "createElement", + target: "ES2018", + lib: ["ES2018"], + strict: true, + noUnusedLocals: true, + noUnusedParameters: true, + noImplicitReturns: true, + noFallthroughCasesInSwitch: true, + }, + include: ["components/**/*", "pages/**/*"], + }), + { + encoding: "utf8", + } + ); + await fs.writeFile( + path.join(tempDirPath, "websnacks.ts"), + ` + import { Config } from "${WEBSNACKS_REPO_ROOT}"; + const config: Config = { + watch: [], + }; + export = config; + `, + { + encoding: "utf8", + } + ); + const pagesPath = path.join(tempDirPath, "pages"); + await fs.mkdir(pagesPath); + await fs.writeFile( + path.join(pagesPath, "index.tsx"), + ` + import { createElement } from "${WEBSNACKS_REPO_ROOT}"; + export const page = () => ; + `, + { + encoding: "utf8", + } + ); + const cmd = runCommand( + "node", + [WEBSNACKS_BIN_PATH, "-r", "ts-node/register", "dev"], + { + cwd: tempDirPath, + } + ); + // FIXME: This test is a bit brittle due to relying on timeouts. + await wait(2_000); + cmd.process.kill(); + const stdout = await cmd.complete; + expect(stdout).toStartWith("Listening at"); + }); + }); +}); diff --git a/test/run-e2e.ts b/test/run-e2e.ts new file mode 100644 index 0000000..31a7c8a --- /dev/null +++ b/test/run-e2e.ts @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { fork } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; + +import { shuffle } from "./lib/utils"; + +const TEST_SUITES_DIR = path.join(__dirname, "e2e"); +const files = fs.readdirSync(TEST_SUITES_DIR); +// Shuffle test suites to detect ordering dependencies between them. +shuffle(files); +for (const file of files) { + const fullPath = path.join(TEST_SUITES_DIR, file); + fork(path.relative(process.cwd(), fullPath)).on("exit", (code) => { + if (code !== 0) { + process.exitCode = 1; + } + }); +} diff --git a/test/run-tests.ts b/test/run-tests.ts index 6850b60..f838fb7 100644 --- a/test/run-tests.ts +++ b/test/run-tests.ts @@ -10,7 +10,6 @@ import * as path from "path"; import { shuffle } from "./lib/utils"; const TEST_SUITES_DIR = path.join(__dirname, "test-suites"); - const files = fs.readdirSync(TEST_SUITES_DIR); // Shuffle test suites to detect ordering dependencies between them. shuffle(files); diff --git a/tsconfig-base.json b/tsconfig-base.json index 821f547..fa9f3df 100644 --- a/tsconfig-base.json +++ b/tsconfig-base.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "esModuleInterop": true, "module": "CommonJS", "moduleResolution": "node", "target": "ES2018",