From 7a133f4155a4557372f313a7e49ec001396a6385 Mon Sep 17 00:00:00 2001 From: DudaGod Date: Fri, 26 Sep 2025 22:17:50 +0300 Subject: [PATCH] feat: ability to connect to repl server by port --- src/browser/commands/switchToRepl.ts | 57 +++++++- src/cli/index.ts | 27 +++- src/testplane.ts | 1 + test/src/browser/commands/switchToRepl.ts | 159 +++++++++++++++++++++- test/src/cli/index.js | 33 ++++- 5 files changed, 263 insertions(+), 14 deletions(-) diff --git a/src/browser/commands/switchToRepl.ts b/src/browser/commands/switchToRepl.ts index 7e7fec09e..586de9abd 100644 --- a/src/browser/commands/switchToRepl.ts +++ b/src/browser/commands/switchToRepl.ts @@ -1,5 +1,7 @@ import path from "node:path"; -import type repl from "node:repl"; +import repl from "node:repl"; +import net from "node:net"; +import { Writable, Readable } from "node:stream"; import { getEventListeners } from "node:events"; import chalk from "chalk"; import RuntimeConfig from "../../config/runtime-config"; @@ -39,11 +41,17 @@ export default (browser: Browser): void => { }); }; + const broadcastMessage = (message: string, sockets: net.Socket[]): void => { + for (const s of sockets) { + s.write(message); + } + }; + session.addCommand("switchToRepl", async function (ctx: Record = {}) { const runtimeCfg = RuntimeConfig.getInstance(); const { onReplMode } = browser.state; - if (!runtimeCfg.replMode?.enabled) { + if (!runtimeCfg.replMode || !runtimeCfg.replMode.enabled) { throw new Error( 'Command "switchToRepl" available only in REPL mode, which can be started using cli option: "--repl", "--repl-before-test" or "--repl-on-fail"', ); @@ -54,16 +62,49 @@ export default (browser: Browser): void => { return; } - logger.log(chalk.yellow("You have entered to REPL mode via terminal (test execution timeout is disabled).")); + logger.log( + chalk.yellow( + `You have entered to REPL mode via terminal (test execution timeout is disabled). Port to connect to REPL from other terminals: ${runtimeCfg.replMode.port}`, + ), + ); const currCwd = process.cwd(); const testCwd = path.dirname(session.executionContext.ctx.currentTest.file!); process.chdir(testCwd); - const replServer = await import("node:repl").then(m => m.start({ prompt: "> " })); + let allSockets: net.Socket[] = []; - browser.applyState({ onReplMode: true }); + const input = new Readable({ read(): void {} }); + const output = new Writable({ + write(chunk, _, callback): void { + broadcastMessage(chunk.toString(), [...allSockets, process.stdout]); + callback(); + }, + }); + + const replServer = repl.start({ prompt: "> ", input, output }); + + const netServer = net + .createServer(socket => { + allSockets.push(socket); + socket.on("data", data => { + broadcastMessage(data.toString(), [...allSockets.filter(s => s !== socket), process.stdout]); + input.push(data); + }); + + socket.on("close", () => { + allSockets = allSockets.filter(s => s !== socket); + }); + }) + .listen(runtimeCfg.replMode.port); + + process.stdin.on("data", data => { + broadcastMessage(data.toString(), allSockets); + input.push(data); + }); + + browser.applyState({ onReplMode: true }); runtimeCfg.extend({ replServer }); applyContext(replServer, ctx); @@ -71,6 +112,12 @@ export default (browser: Browser): void => { return new Promise(resolve => { return replServer.on("exit", () => { + netServer.close(); + + for (const socket of allSockets) { + socket.end("The server was closed after the REPL was exited"); + } + process.chdir(currCwd); browser.applyState({ onReplMode: false }); resolve(); diff --git a/src/cli/index.ts b/src/cli/index.ts index 73ac4220a..60ca38c1f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { Command } from "@gemini-testing/commander"; +import getPort from "get-port"; import defaults from "../config/defaults"; import { configOverriding } from "./info"; @@ -75,6 +76,12 @@ export const run = async (opts: TestplaneRunOpts = {}): Promise => { ) .option("--repl-before-test [type]", "open repl interface before test run", Boolean, false) .option("--repl-on-fail [type]", "open repl interface on test fail only", Boolean, false) + .option( + "--repl-port ", + "run net server on port to exchange messages with repl (used free random port by default)", + Number, + 0, + ) .option("--devtools", "switches the browser to the devtools mode with using CDP protocol") .option("--local", "use local browsers, managed by testplane (same as 'gridUrl': 'local')") .option("--keep-browser", "do not close browser session after test completion") @@ -90,7 +97,6 @@ export const run = async (opts: TestplaneRunOpts = {}): Promise => { updateRefs, inspect, inspectBrk, - repl, replBeforeTest, replOnFail, devtools, @@ -108,9 +114,10 @@ export const run = async (opts: TestplaneRunOpts = {}): Promise => { requireModules, inspectMode: (inspect || inspectBrk) && { inspect, inspectBrk }, replMode: { - enabled: repl || replBeforeTest || replOnFail, + enabled: isReplModeEnabled(program), beforeTest: replBeforeTest, onFail: replOnFail, + port: await getReplPort(program), }, devtools: devtools || false, local: local || false, @@ -148,3 +155,19 @@ function preparseOption(program: Command, option: string): unknown { configFileParser.parse(process.argv); return configFileParser[option]; } + +function isReplModeEnabled(program: Command): boolean { + const { repl, replBeforeTest, replOnFail } = program; + + return repl || replBeforeTest || replOnFail; +} + +async function getReplPort(program: Command): Promise { + let { replPort } = program; + + if (isReplModeEnabled(program) && !replPort) { + replPort = await getPort(); + } + + return replPort; +} diff --git a/src/testplane.ts b/src/testplane.ts index 0b3cdfc07..a7b5850ec 100644 --- a/src/testplane.ts +++ b/src/testplane.ts @@ -35,6 +35,7 @@ interface RunOpts { enabled: boolean; beforeTest: boolean; onFail: boolean; + port: number; }; devtools: boolean; local: boolean; diff --git a/test/src/browser/commands/switchToRepl.ts b/test/src/browser/commands/switchToRepl.ts index b1c4a1e14..e95118516 100644 --- a/test/src/browser/commands/switchToRepl.ts +++ b/test/src/browser/commands/switchToRepl.ts @@ -1,4 +1,6 @@ import repl, { type REPLServer } from "node:repl"; +import net from "node:net"; +import { PassThrough } from "node:stream"; import { EventEmitter } from "node:events"; import proxyquire from "proxyquire"; import chalk from "chalk"; @@ -11,12 +13,17 @@ import type { ExistingBrowser as ExistingBrowserOriginal } from "src/browser/exi describe('"switchToRepl" command', () => { const sandbox = sinon.createSandbox(); + const stdinStub = new PassThrough(); + const stdoutStub = new PassThrough(); + const originalStdin = process.stdin; + const originalStdout = process.stdout; let ExistingBrowser: typeof ExistingBrowserOriginal; let logStub: SinonStub; let warnStub: SinonStub; let webdriverioAttachStub: SinonStub; let clientBridgeBuildStub; + let netCreateServerCb: (socket: net.Socket) => void; const initBrowser_ = ({ browser = mkBrowser_(undefined, undefined, ExistingBrowser), @@ -39,6 +46,28 @@ describe('"switchToRepl" command', () => { return replServer; }; + const mkNetServer_ = (): net.Server => { + const netServer = new EventEmitter() as net.Server; + netServer.listen = sandbox.stub().named("listen").returnsThis(); + netServer.close = sandbox.stub().named("close").returnsThis(); + + (sandbox.stub(net, "createServer") as SinonStub).callsFake(cb => { + netCreateServerCb = cb; + return netServer; + }); + + return netServer; + }; + + const mkSocket_ = (): net.Socket => { + const socket = new EventEmitter() as net.Socket; + + socket.write = sandbox.stub().named("write").returns(true); + socket.end = sandbox.stub().named("end").returnsThis(); + + return socket; + }; + const switchToRepl_ = async ({ session = mkSessionStub_(), replServer = mkReplServer_(), @@ -72,9 +101,24 @@ describe('"switchToRepl" command', () => { sandbox.stub(RuntimeConfig, "getInstance").returns({ replMode: { enabled: false }, extend: sinon.stub() }); sandbox.stub(process, "chdir"); + + Object.defineProperty(process, "stdin", { + value: stdinStub, + configurable: true, + }); + Object.defineProperty(process, "stdout", { + value: stdoutStub, + configurable: true, + }); + sandbox.stub(stdoutStub, "write"); }); - afterEach(() => sandbox.restore()); + afterEach(() => { + sandbox.restore(); + + Object.defineProperty(process, "stdin", { value: originalStdin }); + Object.defineProperty(process, "sdout", { value: originalStdout }); + }); it("should add command", async () => { const session = mkSessionStub_(); @@ -97,8 +141,14 @@ describe('"switchToRepl" command', () => { }); describe("in REPL mode", async () => { + let netServer!: net.Server; + beforeEach(() => { - (RuntimeConfig.getInstance as SinonStub).returns({ replMode: { enabled: true }, extend: sinon.stub() }); + netServer = mkNetServer_(); + (RuntimeConfig.getInstance as SinonStub).returns({ + replMode: { enabled: true, port: 12345 }, + extend: sinon.stub(), + }); }); it("should inform that user entered to repl server before run it", async () => { @@ -107,12 +157,13 @@ describe('"switchToRepl" command', () => { await initBrowser_({ session }); await switchToRepl_({ session }); - assert.callOrder( - (logStub as SinonStub).withArgs( - chalk.yellow("You have entered to REPL mode via terminal (test execution timeout is disabled)."), + assert.calledOnceWith( + logStub, + chalk.yellow( + "You have entered to REPL mode via terminal (test execution timeout is disabled). Port to connect to REPL from other terminals: 12345", ), - repl.start as SinonStub, ); + assert.callOrder(logStub as SinonStub, repl.start as SinonStub); }); it("should change cwd to test directory before run repl server", async () => { @@ -256,5 +307,101 @@ describe('"switchToRepl" command', () => { }); }); }); + + describe("net server", () => { + it("should create server with listen port from runtime config", async () => { + const runtimeCfg = { replMode: { enabled: true, port: 33333 }, extend: sinon.stub() }; + (RuntimeConfig.getInstance as SinonStub).returns(runtimeCfg); + + const session = mkSessionStub_(); + + await initBrowser_({ session }); + await switchToRepl_({ session }); + + assert.calledOnceWith(netServer.listen, 33333); + }); + + it("should broadcast message from stdin to connected sockets", async () => { + const socket1 = mkSocket_(); + const socket2 = mkSocket_(); + const session = mkSessionStub_(); + + await initBrowser_({ session }); + await switchToRepl_({ session }); + + netCreateServerCb(socket1); + netCreateServerCb(socket2); + stdinStub.write("o.O"); + + assert.calledOnceWith(socket1.write, "o.O"); + assert.calledOnceWith(socket2.write, "o.O"); + }); + + it("should broadcast message from socket to other sockets and stdin", async () => { + const socket1 = mkSocket_(); + const socket2 = mkSocket_(); + const session = mkSessionStub_(); + + await initBrowser_({ session }); + await switchToRepl_({ session }); + + netCreateServerCb(socket1); + netCreateServerCb(socket2); + socket1.emit("data", Buffer.from("o.O")); + + assert.notCalled(socket1.write as SinonStub); + assert.calledOnceWith(socket2.write, "o.O"); + assert.calledOnceWith(process.stdout.write, "o.O"); + }); + + it("should not broadcast message to closed socket", async () => { + const socket1 = mkSocket_(); + const socket2 = mkSocket_(); + const session = mkSessionStub_(); + + await initBrowser_({ session }); + await switchToRepl_({ session }); + + netCreateServerCb(socket1); + netCreateServerCb(socket2); + + socket1.emit("close"); + stdinStub.write("o.O"); + + assert.notCalled(socket1.write as SinonStub); + assert.calledOnceWith(socket2.write, "o.O"); + }); + + it("should close net server on exit from repl", async () => { + const session = mkSessionStub_(); + const replServer = mkReplServer_(); + + await initBrowser_({ session }); + const promise = session.switchToRepl(); + replServer.emit("exit"); + await promise; + + assert.calledOnceWith(netServer.close); + }); + + it("should end sockets on exit from repl", async () => { + const socket1 = mkSocket_(); + const socket2 = mkSocket_(); + const session = mkSessionStub_(); + const replServer = mkReplServer_(); + + await initBrowser_({ session }); + const promise = session.switchToRepl(); + + netCreateServerCb(socket1); + netCreateServerCb(socket2); + + replServer.emit("exit"); + await promise; + + assert.calledOnceWith(socket1.end, "The server was closed after the REPL was exited"); + assert.calledOnceWith(socket2.end, "The server was closed after the REPL was exited"); + }); + }); }); }); diff --git a/test/src/cli/index.js b/test/src/cli/index.js index 82a1d49b4..a5e621092 100644 --- a/test/src/cli/index.js +++ b/test/src/cli/index.js @@ -12,7 +12,7 @@ const any = sinon.match.any; describe("cli", () => { const sandbox = sinon.createSandbox(); let testplaneCli; - let loggerLogStub, loggerWarnStub, loggerErrorStub; + let loggerLogStub, loggerWarnStub, loggerErrorStub, getPortStub; const run_ = async (argv = "", cli) => { process.argv = ["foo/bar/node", "foo/bar/script", ...argv.split(" ")]; @@ -27,6 +27,7 @@ describe("cli", () => { loggerLogStub = sandbox.stub(); loggerWarnStub = sandbox.stub(); loggerErrorStub = sandbox.stub(); + getPortStub = sandbox.stub().resolves(12345); testplaneCli = proxyquire("src/cli", { "../utils/cli": proxyquire("src/utils/cli", { @@ -41,6 +42,7 @@ describe("cli", () => { warn: loggerWarnStub, error: loggerErrorStub, }, + "get-port": getPortStub, }); sandbox.stub(Testplane, "create").returns(Object.create(Testplane.prototype)); @@ -274,6 +276,7 @@ describe("cli", () => { enabled: false, beforeTest: false, onFail: false, + port: 0, }, }); }); @@ -286,6 +289,7 @@ describe("cli", () => { enabled: true, beforeTest: false, onFail: false, + port: 12345, }, }); }); @@ -298,6 +302,7 @@ describe("cli", () => { enabled: true, beforeTest: true, onFail: false, + port: 12345, }, }); }); @@ -310,6 +315,32 @@ describe("cli", () => { enabled: true, beforeTest: false, onFail: true, + port: 12345, + }, + }); + }); + + it('should use passed port when specify "port" option', async () => { + await run_("--repl --repl-port 33333"); + + assert.notCalled(getPortStub); + assert.calledWithMatch(Testplane.prototype.run, any, { + replMode: { + enabled: true, + port: 33333, + }, + }); + }); + + it('should use random free port if "port" option is not specified', async () => { + getPortStub.resolves(44444); + + await run_("--repl"); + + assert.calledWithMatch(Testplane.prototype.run, any, { + replMode: { + enabled: true, + port: 44444, }, }); });