Skip to content

Commit 2491a6a

Browse files
committed
feat: ability to connect to repl server by port
1 parent 8bfcb6e commit 2491a6a

File tree

5 files changed

+263
-14
lines changed

5 files changed

+263
-14
lines changed

src/browser/commands/switchToRepl.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import path from "node:path";
2-
import type repl from "node:repl";
2+
import repl from "node:repl";
3+
import net from "node:net";
4+
import { Writable, Readable } from "node:stream";
35
import { getEventListeners } from "node:events";
46
import chalk from "chalk";
57
import RuntimeConfig from "../../config/runtime-config";
@@ -39,11 +41,17 @@ export default (browser: Browser): void => {
3941
});
4042
};
4143

44+
const broadcastMessage = (message: string, sockets: net.Socket[]): void => {
45+
for (const s of sockets) {
46+
s.write(message);
47+
}
48+
};
49+
4250
session.addCommand("switchToRepl", async function (ctx: Record<string, unknown> = {}) {
4351
const runtimeCfg = RuntimeConfig.getInstance();
4452
const { onReplMode } = browser.state;
4553

46-
if (!runtimeCfg.replMode?.enabled) {
54+
if (!runtimeCfg.replMode || !runtimeCfg.replMode.enabled) {
4755
throw new Error(
4856
'Command "switchToRepl" available only in REPL mode, which can be started using cli option: "--repl", "--repl-before-test" or "--repl-on-fail"',
4957
);
@@ -54,23 +62,62 @@ export default (browser: Browser): void => {
5462
return;
5563
}
5664

57-
logger.log(chalk.yellow("You have entered to REPL mode via terminal (test execution timeout is disabled)."));
65+
logger.log(
66+
chalk.yellow(
67+
`You have entered to REPL mode via terminal (test execution timeout is disabled). Port to connect to REPL from other terminals: ${runtimeCfg.replMode.port}`,
68+
),
69+
);
5870

5971
const currCwd = process.cwd();
6072
const testCwd = path.dirname(session.executionContext.ctx.currentTest.file!);
6173
process.chdir(testCwd);
6274

63-
const replServer = await import("node:repl").then(m => m.start({ prompt: "> " }));
75+
let allSockets: net.Socket[] = [];
6476

65-
browser.applyState({ onReplMode: true });
77+
const input = new Readable({ read(): void {} });
78+
const output = new Writable({
79+
write(chunk, _, callback): void {
80+
broadcastMessage(chunk.toString(), [...allSockets, process.stdout]);
81+
callback();
82+
},
83+
});
84+
85+
const replServer = repl.start({ prompt: "> ", input, output });
86+
87+
const netServer = net
88+
.createServer(socket => {
89+
allSockets.push(socket);
6690

91+
socket.on("data", data => {
92+
broadcastMessage(data.toString(), [...allSockets.filter(s => s !== socket), process.stdout]);
93+
input.push(data);
94+
});
95+
96+
socket.on("close", () => {
97+
allSockets = allSockets.filter(s => s !== socket);
98+
});
99+
})
100+
.listen(runtimeCfg.replMode.port);
101+
102+
process.stdin.on("data", data => {
103+
broadcastMessage(data.toString(), allSockets);
104+
input.push(data);
105+
});
106+
107+
browser.applyState({ onReplMode: true });
67108
runtimeCfg.extend({ replServer });
68109

69110
applyContext(replServer, ctx);
70111
handleLines(replServer);
71112

72113
return new Promise<void>(resolve => {
73114
return replServer.on("exit", () => {
115+
netServer.close();
116+
117+
for (const socket of allSockets) {
118+
socket.end("The server was closed after the REPL was exited");
119+
}
120+
74121
process.chdir(currCwd);
75122
browser.applyState({ onReplMode: false });
76123
resolve();

src/cli/index.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import path from "node:path";
22
import { Command } from "@gemini-testing/commander";
3+
import getPort from "get-port";
34

45
import defaults from "../config/defaults";
56
import { configOverriding } from "./info";
@@ -75,6 +76,12 @@ export const run = async (opts: TestplaneRunOpts = {}): Promise<void> => {
7576
)
7677
.option("--repl-before-test [type]", "open repl interface before test run", Boolean, false)
7778
.option("--repl-on-fail [type]", "open repl interface on test fail only", Boolean, false)
79+
.option(
80+
"--repl-port <number>",
81+
"run net server on port to exchange messages with repl (used free random port by default)",
82+
Number,
83+
0,
84+
)
7885
.option("--devtools", "switches the browser to the devtools mode with using CDP protocol")
7986
.option("--local", "use local browsers, managed by testplane (same as 'gridUrl': 'local')")
8087
.option("--keep-browser", "do not close browser session after test completion")
@@ -90,7 +97,6 @@ export const run = async (opts: TestplaneRunOpts = {}): Promise<void> => {
9097
updateRefs,
9198
inspect,
9299
inspectBrk,
93-
repl,
94100
replBeforeTest,
95101
replOnFail,
96102
devtools,
@@ -108,9 +114,10 @@ export const run = async (opts: TestplaneRunOpts = {}): Promise<void> => {
108114
requireModules,
109115
inspectMode: (inspect || inspectBrk) && { inspect, inspectBrk },
110116
replMode: {
111-
enabled: repl || replBeforeTest || replOnFail,
117+
enabled: isReplModeEnabled(program),
112118
beforeTest: replBeforeTest,
113119
onFail: replOnFail,
120+
port: await getReplPort(program),
114121
},
115122
devtools: devtools || false,
116123
local: local || false,
@@ -148,3 +155,19 @@ function preparseOption(program: Command, option: string): unknown {
148155
configFileParser.parse(process.argv);
149156
return configFileParser[option];
150157
}
158+
159+
function isReplModeEnabled(program: Command): boolean {
160+
const { repl, replBeforeTest, replOnFail } = program;
161+
162+
return repl || replBeforeTest || replOnFail;
163+
}
164+
165+
async function getReplPort(program: Command): Promise<number> {
166+
let { replPort } = program;
167+
168+
if (isReplModeEnabled(program) && !replPort) {
169+
replPort = await getPort();
170+
}
171+
172+
return replPort;
173+
}

src/testplane.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ interface RunOpts {
3535
enabled: boolean;
3636
beforeTest: boolean;
3737
onFail: boolean;
38+
port: number;
3839
};
3940
devtools: boolean;
4041
local: boolean;

test/src/browser/commands/switchToRepl.ts

Lines changed: 153 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import repl, { type REPLServer } from "node:repl";
2+
import net from "node:net";
3+
import { PassThrough } from "node:stream";
24
import { EventEmitter } from "node:events";
35
import proxyquire from "proxyquire";
46
import chalk from "chalk";
@@ -11,12 +13,17 @@ import type { ExistingBrowser as ExistingBrowserOriginal } from "src/browser/exi
1113

1214
describe('"switchToRepl" command', () => {
1315
const sandbox = sinon.createSandbox();
16+
const stdinStub = new PassThrough();
17+
const stdoutStub = new PassThrough();
18+
const originalStdin = process.stdin;
19+
const originalStdout = process.stdout;
1420

1521
let ExistingBrowser: typeof ExistingBrowserOriginal;
1622
let logStub: SinonStub;
1723
let warnStub: SinonStub;
1824
let webdriverioAttachStub: SinonStub;
1925
let clientBridgeBuildStub;
26+
let netCreateServerCb: (socket: net.Socket) => void;
2027

2128
const initBrowser_ = ({
2229
browser = mkBrowser_(undefined, undefined, ExistingBrowser),
@@ -39,6 +46,28 @@ describe('"switchToRepl" command', () => {
3946
return replServer;
4047
};
4148

49+
const mkNetServer_ = (): net.Server => {
50+
const netServer = new EventEmitter() as net.Server;
51+
netServer.listen = sandbox.stub().named("listen").returnsThis();
52+
netServer.close = sandbox.stub().named("close").returnsThis();
53+
54+
(sandbox.stub(net, "createServer") as SinonStub).callsFake(cb => {
55+
netCreateServerCb = cb;
56+
return netServer;
57+
});
58+
59+
return netServer;
60+
};
61+
62+
const mkSocket_ = (): net.Socket => {
63+
const socket = new EventEmitter() as net.Socket;
64+
65+
socket.write = sandbox.stub().named("write").returns(true);
66+
socket.end = sandbox.stub().named("end").returnsThis();
67+
68+
return socket;
69+
};
70+
4271
const switchToRepl_ = async ({
4372
session = mkSessionStub_(),
4473
replServer = mkReplServer_(),
@@ -72,9 +101,24 @@ describe('"switchToRepl" command', () => {
72101

73102
sandbox.stub(RuntimeConfig, "getInstance").returns({ replMode: { enabled: false }, extend: sinon.stub() });
74103
sandbox.stub(process, "chdir");
104+
105+
Object.defineProperty(process, "stdin", {
106+
value: stdinStub,
107+
configurable: true,
108+
});
109+
Object.defineProperty(process, "stdout", {
110+
value: stdoutStub,
111+
configurable: true,
112+
});
113+
sandbox.stub(stdoutStub, "write");
75114
});
76115

77-
afterEach(() => sandbox.restore());
116+
afterEach(() => {
117+
sandbox.restore();
118+
119+
Object.defineProperty(process, "stdin", { value: originalStdin });
120+
Object.defineProperty(process, "sdout", { value: originalStdout });
121+
});
78122

79123
it("should add command", async () => {
80124
const session = mkSessionStub_();
@@ -97,8 +141,14 @@ describe('"switchToRepl" command', () => {
97141
});
98142

99143
describe("in REPL mode", async () => {
144+
let netServer!: net.Server;
145+
100146
beforeEach(() => {
101-
(RuntimeConfig.getInstance as SinonStub).returns({ replMode: { enabled: true }, extend: sinon.stub() });
147+
netServer = mkNetServer_();
148+
(RuntimeConfig.getInstance as SinonStub).returns({
149+
replMode: { enabled: true, port: 12345 },
150+
extend: sinon.stub(),
151+
});
102152
});
103153

104154
it("should inform that user entered to repl server before run it", async () => {
@@ -107,12 +157,13 @@ describe('"switchToRepl" command', () => {
107157
await initBrowser_({ session });
108158
await switchToRepl_({ session });
109159

110-
assert.callOrder(
111-
(logStub as SinonStub).withArgs(
112-
chalk.yellow("You have entered to REPL mode via terminal (test execution timeout is disabled)."),
160+
assert.calledOnceWith(
161+
logStub,
162+
chalk.yellow(
163+
"You have entered to REPL mode via terminal (test execution timeout is disabled). Port to connect to REPL from other terminals: 12345",
113164
),
114-
repl.start as SinonStub,
115165
);
166+
assert.callOrder(logStub as SinonStub, repl.start as SinonStub);
116167
});
117168

118169
it("should change cwd to test directory before run repl server", async () => {
@@ -256,5 +307,101 @@ describe('"switchToRepl" command', () => {
256307
});
257308
});
258309
});
310+
311+
describe.only("net server", () => {
312+
it("should create server with listen port from runtime config", async () => {
313+
const runtimeCfg = { replMode: { enabled: true, port: 33333 }, extend: sinon.stub() };
314+
(RuntimeConfig.getInstance as SinonStub).returns(runtimeCfg);
315+
316+
const session = mkSessionStub_();
317+
318+
await initBrowser_({ session });
319+
await switchToRepl_({ session });
320+
321+
assert.calledOnceWith(netServer.listen, 33333);
322+
});
323+
324+
it("should broadcast message from stdin to connected sockets", async () => {
325+
const socket1 = mkSocket_();
326+
const socket2 = mkSocket_();
327+
const session = mkSessionStub_();
328+
329+
await initBrowser_({ session });
330+
await switchToRepl_({ session });
331+
332+
netCreateServerCb(socket1);
333+
netCreateServerCb(socket2);
334+
stdinStub.write("o.O");
335+
336+
assert.calledOnceWith(socket1.write, "o.O");
337+
assert.calledOnceWith(socket2.write, "o.O");
338+
});
339+
340+
it("should broadcast message from socket to other sockets and stdin", async () => {
341+
const socket1 = mkSocket_();
342+
const socket2 = mkSocket_();
343+
const session = mkSessionStub_();
344+
345+
await initBrowser_({ session });
346+
await switchToRepl_({ session });
347+
348+
netCreateServerCb(socket1);
349+
netCreateServerCb(socket2);
350+
socket1.emit("data", Buffer.from("o.O"));
351+
352+
assert.notCalled(socket1.write as SinonStub);
353+
assert.calledOnceWith(socket2.write, "o.O");
354+
assert.calledOnceWith(process.stdout.write, "o.O");
355+
});
356+
357+
it("should not broadcast message to closed socket", async () => {
358+
const socket1 = mkSocket_();
359+
const socket2 = mkSocket_();
360+
const session = mkSessionStub_();
361+
362+
await initBrowser_({ session });
363+
await switchToRepl_({ session });
364+
365+
netCreateServerCb(socket1);
366+
netCreateServerCb(socket2);
367+
368+
socket1.emit("close");
369+
stdinStub.write("o.O");
370+
371+
assert.notCalled(socket1.write as SinonStub);
372+
assert.calledOnceWith(socket2.write, "o.O");
373+
});
374+
375+
it("should close net server on exit from repl", async () => {
376+
const session = mkSessionStub_();
377+
const replServer = mkReplServer_();
378+
379+
await initBrowser_({ session });
380+
const promise = session.switchToRepl();
381+
replServer.emit("exit");
382+
await promise;
383+
384+
assert.calledOnceWith(netServer.close);
385+
});
386+
387+
it("should end sockets on exit from repl", async () => {
388+
const socket1 = mkSocket_();
389+
const socket2 = mkSocket_();
390+
const session = mkSessionStub_();
391+
const replServer = mkReplServer_();
392+
393+
await initBrowser_({ session });
394+
const promise = session.switchToRepl();
395+
396+
netCreateServerCb(socket1);
397+
netCreateServerCb(socket2);
398+
399+
replServer.emit("exit");
400+
await promise;
401+
402+
assert.calledOnceWith(socket1.end, "The server was closed after the REPL was exited");
403+
assert.calledOnceWith(socket2.end, "The server was closed after the REPL was exited");
404+
});
405+
});
259406
});
260407
});

0 commit comments

Comments
 (0)