From ab0004e828a46f3c5f1f2e669c674f798681fcd8 Mon Sep 17 00:00:00 2001 From: Derpius <49565664+Derpius@users.noreply.github.com> Date: Tue, 4 Jul 2023 20:23:20 +0100 Subject: [PATCH 1/3] feat: add public API to lest package BREAKING CHANGE: this replaces the old export which ran Lest --- packages/lest/package.json | 12 ++- packages/lest/src/bin/lest.ts | 23 +++++ packages/lest/src/index.ts | 29 +------ packages/lest/src/lest-process.ts | 67 +++++++++++++++ packages/lest/tests/lest-process.test.ts | 102 +++++++++++++++++++++++ 5 files changed, 198 insertions(+), 35 deletions(-) create mode 100644 packages/lest/src/bin/lest.ts create mode 100644 packages/lest/src/lest-process.ts create mode 100644 packages/lest/tests/lest-process.test.ts diff --git a/packages/lest/package.json b/packages/lest/package.json index 7526b81..6d3991f 100644 --- a/packages/lest/package.json +++ b/packages/lest/package.json @@ -9,13 +9,11 @@ "url": "https://github.com/TAServers/lest", "directory": "packages/lest" }, - "main": "dist/index.js", - "exports": { - ".": "dist/index.js" - }, - "types": "dist/index.d.ts", + "main": "./dist/index.js", + "exports": "./dist/index.js", + "types": "./dist/index.d.ts", "bin": { - "lest": "dist/index.js" + "lest": "./dist/bin/lest.js" }, "scripts": { "build:lua": "luabundler bundle src/lua/lest.lua -p \"./?.lua\" -o dist/lua/lest.lua", @@ -25,7 +23,7 @@ "test:ts": "jest", "test": "npm run test:lua && npm run test:ts", "clean": "rimraf dist", - "start": "ts-node src/index.ts" + "start": "ts-node src/bin/lest.ts" }, "devDependencies": { "@types/jest": "^29.5.2", diff --git a/packages/lest/src/bin/lest.ts b/packages/lest/src/bin/lest.ts new file mode 100644 index 0000000..1b6a0fd --- /dev/null +++ b/packages/lest/src/bin/lest.ts @@ -0,0 +1,23 @@ +#!/usr/bin/env node + +import minimist from "minimist"; +import { LestProcess, LestEvent } from "../lest-process"; + +const { luaCommand } = minimist(process.argv.slice(2)); + +const lestProcess = new LestProcess(luaCommand); + +lestProcess + .addListener(LestEvent.StdOut, (data: Buffer) => { + process.stdout.write(data); + }) + .addListener(LestEvent.StdErr, (data: Buffer) => { + process.stderr.write(data); + }) + .addListener(LestEvent.Error, (error) => { + console.error(`Failed to run command:\n${error.message}`); + }); + +lestProcess.run(process.argv.slice(2)).catch((error) => { + console.error(`Unhandled error occurred:\n${error.message}`); +}); diff --git a/packages/lest/src/index.ts b/packages/lest/src/index.ts index 1ca549e..96f3630 100644 --- a/packages/lest/src/index.ts +++ b/packages/lest/src/index.ts @@ -1,28 +1 @@ -#!/usr/bin/env node - -import minimist from "minimist"; -import path from "path"; -import { findLuaExecutable } from "./helpers"; -import { spawn } from "child_process"; - -const { luaCommand } = minimist(process.argv.slice(2)); - -const getLestLuaPath = () => path.join(__dirname, "lua", "lest.lua"); - -findLuaExecutable(luaCommand).then((executablePath) => { - const scriptPath = getLestLuaPath(); - - const lestProcess = spawn(executablePath, [scriptPath, ...process.argv.slice(2)]); - - lestProcess.stdout.on("data", (data: Buffer) => { - process.stdout.write(data); - }); - - lestProcess.stderr.on("data", (data: Buffer) => { - process.stderr.write(data); - }); - - lestProcess.on("error", (error) => { - console.error(`Failed to run command:\n${error.message}`); - }); -}); +export * from "./lest-process"; diff --git a/packages/lest/src/lest-process.ts b/packages/lest/src/lest-process.ts new file mode 100644 index 0000000..a90d169 --- /dev/null +++ b/packages/lest/src/lest-process.ts @@ -0,0 +1,67 @@ +import path from "path"; +import { findLuaExecutable } from "./helpers"; +import { spawn } from "child_process"; + +export enum LestEvent { + StdOut, + StdErr, + Error, +} + +export type LestEventListener = Event extends LestEvent.StdOut + ? (data: Buffer) => void + : Event extends LestEvent.StdErr + ? (data: Buffer) => void + : Event extends LestEvent.Error + ? (error: Error) => void + : never; + +export class LestProcess { + private eventListeners: { [K in LestEvent]: Set> } = { + [LestEvent.StdOut]: new Set(), + [LestEvent.StdErr]: new Set(), + [LestEvent.Error]: new Set(), + }; + + readonly scriptPath: string; + + constructor(readonly luaCommand?: string) { + this.scriptPath = path.join(__dirname, "lua", "lest.lua"); + } + + async run(args: string[] = []) { + const executablePath = await findLuaExecutable(this.luaCommand); + const process = spawn(executablePath, [this.scriptPath, ...args]); + + process.stdout.on("data", (data: Buffer) => { + this.triggerEvent(LestEvent.StdOut, data); + }); + + process.stderr.on("data", (data: Buffer) => { + this.triggerEvent(LestEvent.StdErr, data); + }); + + process.on("error", (error) => { + this.triggerEvent(LestEvent.Error, error); + }); + } + + private triggerEvent(event: Event, ...args: Parameters>) { + this.eventListeners[event].forEach((listener: LestEventListener) => + listener( + // @ts-expect-error + ...args + ) + ); + } + + addListener(event: Event, listener: LestEventListener): this { + this.eventListeners[event].add(listener); + return this; + } + + removeListener(event: Event, listener: LestEventListener): this { + this.eventListeners[event].delete(listener); + return this; + } +} diff --git a/packages/lest/tests/lest-process.test.ts b/packages/lest/tests/lest-process.test.ts new file mode 100644 index 0000000..95a366d --- /dev/null +++ b/packages/lest/tests/lest-process.test.ts @@ -0,0 +1,102 @@ +import { LestEvent, LestProcess } from "../src"; +import { spawn } from "child_process"; +import { findLuaExecutable } from "../src/helpers"; + +const mockStdOutOn = jest.fn(); +const mockStdErrOn = jest.fn(); +const mockOn = jest.fn(); + +const SCRIPT_PATH = expect.stringMatching(/.+[\/\\]lua[\/\\]lest\.lua/); + +jest.mock("child_process", () => ({ + spawn: jest.fn(() => ({ + stdout: { on: mockStdOutOn }, + stderr: { on: mockStdErrOn }, + on: mockOn, + })), +})); + +jest.mock("../src/helpers", () => ({ + findLuaExecutable: jest.fn(), +})); + +test("spawns the Lest process with the given args", async () => { + const luaPath = "/path/to/lua.exe"; + const args = ["--testMatch", "1234"]; + + jest.mocked(findLuaExecutable).mockResolvedValue(luaPath); + const process = new LestProcess(); + + await process.run(args); + + expect(jest.mocked(spawn)).toHaveBeenCalledWith(luaPath, [SCRIPT_PATH, ...args]); +}); + +test("spawns the Lest process with the given luaCommand", async () => { + jest.mocked(findLuaExecutable).mockImplementation((cmd = "") => Promise.resolve(cmd)); + const luaCommand = "/path/to/lua.exe"; + + const process = new LestProcess(luaCommand); + + await process.run(); + + expect(jest.mocked(spawn)).toHaveBeenCalledWith(luaCommand, [SCRIPT_PATH]); +}); + +interface ListenerTestCase { + event: keyof typeof LestEvent; + onMock: jest.Mock; + args: any[]; + expected: any[]; +} + +const listenerTestCases: ListenerTestCase[] = [ + { + event: "StdOut", + onMock: mockStdOutOn, + args: [Buffer.from("blah")], + expected: [Buffer.from("blah")], + }, + { + event: "StdErr", + onMock: mockStdErrOn, + args: [Buffer.from("blah")], + expected: [Buffer.from("blah")], + }, + { + event: "Error", + onMock: mockOn, + args: [new Error("blah")], + expected: [new Error("blah")], + }, +]; + +describe.each(listenerTestCases)("event: $event", ({ event, onMock, args, expected }) => { + test("calls listeners", async () => { + onMock.mockImplementation((_, callback) => callback(...args)); + const listener1 = jest.fn(); + const listener2 = jest.fn(); + + const process = new LestProcess(); + + process.addListener(LestEvent[event], listener1); + process.addListener(LestEvent[event], listener2); + await process.run(); + + expect(listener1).toHaveBeenCalledWith(...expected); + expect(listener2).toHaveBeenCalledWith(...expected); + }); + + test("doesn't call listeners that have been removed", async () => { + onMock.mockImplementation((_, callback) => callback(...args)); + const listener = jest.fn(); + + const process = new LestProcess(); + + process.addListener(LestEvent.StdOut, listener); + process.removeListener(LestEvent.StdOut, listener); + await process.run(); + + expect(listener).not.toHaveBeenCalled(); + }); +}); From e8f10e8442011c68ef1f87b38163a7d43bcef1e7 Mon Sep 17 00:00:00 2001 From: Derpius <49565664+Derpius@users.noreply.github.com> Date: Sun, 9 Jul 2023 14:48:26 +0100 Subject: [PATCH 2/3] chore: update package lock --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 475bfaa..7af7a2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16399,7 +16399,7 @@ "minimist": "^1.2.8" }, "bin": { - "lest": "dist/index.js" + "lest": "dist/bin/lest.js" }, "devDependencies": { "@types/jest": "^29.5.2", From f89be25a16ba957fdc2d126c6270e40a4ca3a86e Mon Sep 17 00:00:00 2001 From: Derpius <49565664+Derpius@users.noreply.github.com> Date: Sun, 9 Jul 2023 14:50:22 +0100 Subject: [PATCH 3/3] fix: exit with status based on whether tests passed --- packages/lest/src/bin/lest.ts | 9 +++-- packages/lest/src/lest-process.ts | 8 ++++- packages/lest/tests/lest-process.test.ts | 45 ++++++++++++++++++------ 3 files changed, 48 insertions(+), 14 deletions(-) diff --git a/packages/lest/src/bin/lest.ts b/packages/lest/src/bin/lest.ts index 1b6a0fd..86680fe 100644 --- a/packages/lest/src/bin/lest.ts +++ b/packages/lest/src/bin/lest.ts @@ -18,6 +18,9 @@ lestProcess console.error(`Failed to run command:\n${error.message}`); }); -lestProcess.run(process.argv.slice(2)).catch((error) => { - console.error(`Unhandled error occurred:\n${error.message}`); -}); +lestProcess + .run(process.argv.slice(2)) + .then((success) => process.exit(success ? 0 : 1)) + .catch((error) => { + console.error(`Unhandled error occurred:\n${error.message}`); + }); diff --git a/packages/lest/src/lest-process.ts b/packages/lest/src/lest-process.ts index a90d169..3f2bf30 100644 --- a/packages/lest/src/lest-process.ts +++ b/packages/lest/src/lest-process.ts @@ -29,7 +29,7 @@ export class LestProcess { this.scriptPath = path.join(__dirname, "lua", "lest.lua"); } - async run(args: string[] = []) { + async run(args: string[] = []): Promise { const executablePath = await findLuaExecutable(this.luaCommand); const process = spawn(executablePath, [this.scriptPath, ...args]); @@ -44,6 +44,12 @@ export class LestProcess { process.on("error", (error) => { this.triggerEvent(LestEvent.Error, error); }); + + return new Promise((resolve) => { + process.on("exit", (code) => { + resolve(code === 0); + }); + }); } private triggerEvent(event: Event, ...args: Parameters>) { diff --git a/packages/lest/tests/lest-process.test.ts b/packages/lest/tests/lest-process.test.ts index 95a366d..9b42ccd 100644 --- a/packages/lest/tests/lest-process.test.ts +++ b/packages/lest/tests/lest-process.test.ts @@ -1,3 +1,4 @@ +import { when, WhenMockWithMatchers } from "jest-when"; import { LestEvent, LestProcess } from "../src"; import { spawn } from "child_process"; import { findLuaExecutable } from "../src/helpers"; @@ -20,14 +21,27 @@ jest.mock("../src/helpers", () => ({ findLuaExecutable: jest.fn(), })); +const runLestProcess = ( + process: LestProcess, + { exitCode = 0, args = [] }: { exitCode?: number; args?: string[] } = {} +) => { + when(mockOn) + .calledWith("exit", expect.anything()) + .mockImplementation((_, callback) => { + callback(exitCode); + }); + + return process.run(args); +}; + test("spawns the Lest process with the given args", async () => { const luaPath = "/path/to/lua.exe"; const args = ["--testMatch", "1234"]; jest.mocked(findLuaExecutable).mockResolvedValue(luaPath); - const process = new LestProcess(); - await process.run(args); + const process = new LestProcess(); + await runLestProcess(process, { args }); expect(jest.mocked(spawn)).toHaveBeenCalledWith(luaPath, [SCRIPT_PATH, ...args]); }); @@ -37,15 +51,26 @@ test("spawns the Lest process with the given luaCommand", async () => { const luaCommand = "/path/to/lua.exe"; const process = new LestProcess(luaCommand); - - await process.run(); + await runLestProcess(process); expect(jest.mocked(spawn)).toHaveBeenCalledWith(luaCommand, [SCRIPT_PATH]); }); +test("returns true when the process exits successfully", async () => { + const process = new LestProcess(); + + await expect(runLestProcess(process, { exitCode: 0 })).resolves.toBe(true); +}); + +test("returns false when the process exits unsuccessfully", async () => { + const process = new LestProcess(); + + await expect(runLestProcess(process, { exitCode: 1 })).resolves.toBe(false); +}); + interface ListenerTestCase { event: keyof typeof LestEvent; - onMock: jest.Mock; + onMock: jest.Mock | WhenMockWithMatchers; args: any[]; expected: any[]; } @@ -53,19 +78,19 @@ interface ListenerTestCase { const listenerTestCases: ListenerTestCase[] = [ { event: "StdOut", - onMock: mockStdOutOn, + onMock: when(mockStdOutOn).calledWith("data", expect.anything()), args: [Buffer.from("blah")], expected: [Buffer.from("blah")], }, { event: "StdErr", - onMock: mockStdErrOn, + onMock: when(mockStdErrOn).calledWith("data", expect.anything()), args: [Buffer.from("blah")], expected: [Buffer.from("blah")], }, { event: "Error", - onMock: mockOn, + onMock: when(mockOn).calledWith("error", expect.anything()), args: [new Error("blah")], expected: [new Error("blah")], }, @@ -81,7 +106,7 @@ describe.each(listenerTestCases)("event: $event", ({ event, onMock, args, expect process.addListener(LestEvent[event], listener1); process.addListener(LestEvent[event], listener2); - await process.run(); + await runLestProcess(process); expect(listener1).toHaveBeenCalledWith(...expected); expect(listener2).toHaveBeenCalledWith(...expected); @@ -95,7 +120,7 @@ describe.each(listenerTestCases)("event: $event", ({ event, onMock, args, expect process.addListener(LestEvent.StdOut, listener); process.removeListener(LestEvent.StdOut, listener); - await process.run(); + await runLestProcess(process); expect(listener).not.toHaveBeenCalled(); });