Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions src/browser/commands/switchToRepl.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import path from "node:path";
import type repl from "node:repl";
import repl, { type REPLEval, type REPLServer, type ReplOptions } 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";
import * as logger from "../../utils/logger";
import { REPL_SCOPED_EVAL_CONTEXT_KEY } from "../../constants/repl";
import type { Browser } from "../types";

const REPL_LINE_EVENT = "line";

type ScopedEval = (cmd: string) => unknown | Promise<unknown>;

export default (browser: Browser): void => {
const { publicAPI: session } = browser;

const applyContext = (replServer: repl.REPLServer, ctx: Record<string, unknown> = {}): void => {
const applyContext = (replServer: REPLServer, ctx: Record<string, unknown> = {}): void => {
if (!ctx.browser) {
ctx.browser = session;
}
Expand All @@ -27,7 +30,7 @@ export default (browser: Browser): void => {
}
};

const handleLines = (replServer: repl.REPLServer): void => {
const handleLines = (replServer: REPLServer): void => {
const lineEvents = getEventListeners(replServer, REPL_LINE_EVENT);
replServer.removeAllListeners(REPL_LINE_EVENT);

Expand All @@ -47,9 +50,22 @@ export default (browser: Browser): void => {
}
};

const mkScopedEval =
(scopedEval: ScopedEval): REPLEval =>
async (cmd, _context, _filename, callback) => {
try {
callback(null, await scopedEval(cmd.replace(/\n$/, "")));
} catch (err) {
callback(err as Error, undefined);
}
};

session.addCommand("switchToRepl", async function (ctx: Record<string, unknown> = {}) {
const runtimeCfg = RuntimeConfig.getInstance();
const { onReplMode } = browser.state;
const scopedEval = ctx[REPL_SCOPED_EVAL_CONTEXT_KEY];
const replContext = { ...ctx };
delete replContext[REPL_SCOPED_EVAL_CONTEXT_KEY];

if (!runtimeCfg.replMode || !runtimeCfg.replMode.enabled) {
throw new Error(
Expand Down Expand Up @@ -82,7 +98,13 @@ export default (browser: Browser): void => {
},
});

const replServer = await import("node:repl").then(repl => repl.start({ prompt: "> ", input, output }));
const replStartOptions: ReplOptions = { prompt: "> ", input, output };

if (typeof scopedEval === "function") {
replStartOptions.eval = mkScopedEval(scopedEval as ScopedEval);
}

const replServer = repl.start(replStartOptions);

const netServer = net
.createServer(socket => {
Expand All @@ -107,7 +129,7 @@ export default (browser: Browser): void => {
browser.applyState({ onReplMode: true });
runtimeCfg.extend({ replServer });

applyContext(replServer, ctx);
applyContext(replServer, replContext);
handleLines(replServer);

return new Promise<void>(resolve => {
Expand Down
2 changes: 2 additions & 0 deletions src/constants/repl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const REPL_SCOPED_EVAL_CONTEXT_KEY = "__testplaneScopedEval";
export const REPL_SCOPED_FN_FLAG = "__testplaneScopedRepl";
68 changes: 65 additions & 3 deletions src/test-reader/mocha-reader/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
"use strict";

const path = require("node:path");
const { fileURLToPath } = require("node:url");
const _ = require("lodash");
const Mocha = require("mocha");
const debugReplInstrumentation = require("debug")("testplane:repl-instrumentation");

const { MochaEventBus } = require("./mocha-event-bus");
const { TreeBuilderDecorator } = require("./tree-builder-decorator");
Expand All @@ -10,6 +13,11 @@ const { MasterEvents } = require("../../events");
const { getMethodsByInterface } = require("./utils");
const logger = require("../../utils/logger");
const { enableSourceMaps } = require("../../utils/typescript");
const RuntimeConfig = require("../../config/runtime-config");

// TS-family specs must hit the require hook when --repl-before-test injects scoped eval.
const REPL_INSTRUMENTATION_FORCE_REQUIRE_EXTENSIONS = new Set([".ts", ".tsx", ".cts", ".mts"]);
const REPL_INSTRUMENTATION_MISSING_FILE_SUFFIX = ".testplane-repl-force-require";

function getTagParser(original) {
return function (title, paramsOrFn, fn) {
Expand Down Expand Up @@ -56,10 +64,14 @@ async function readFiles(files, { esmDecorator, config, eventBus, runnableOpts,
initBuildContext(eventBus);
initEventListeners({ rootSuite: mocha.suite, outBus: eventBus, config, runnableOpts });

files.forEach(f => mocha.addFile(f));
files.forEach(f => {
debugReplInstrumentation("mocha add file %s", f);
mocha.addFile(f);
});

try {
await mocha.loadFilesAsync({ esmDecorator });
// The wrapper preserves normal esmDecorator behavior, but can force Mocha's import->require fallback for REPL instrumentation.
await mocha.loadFilesAsync({ esmDecorator: mkEsmDecorator(esmDecorator) });
} catch (err) {
const errorMessage = (err.message || "").split("\n")[0].trim();

Expand All @@ -77,6 +89,53 @@ async function readFiles(files, { esmDecorator, config, eventBus, runnableOpts,
applyOnly(mocha.suite, eventBus);
}

function mkEsmDecorator(esmDecorator) {
if (!RuntimeConfig.getInstance()?.replMode?.beforeTest) {
return esmDecorator;
}

const decorate = esmDecorator || (file => file);

return file => {
const decoratedFile = decorate(file);
const sourceFile = getFilePath(file);

// Mocha passes file:// URLs here; use the original path to check the actual spec extension.
if (!sourceFile || !REPL_INSTRUMENTATION_FORCE_REQUIRE_EXTENSIONS.has(path.extname(sourceFile))) {
return decoratedFile;
}

debugReplInstrumentation("force require for REPL instrumentation %s", sourceFile);

return mkMissingFileUrl(decoratedFile);
};
}

function getFilePath(file) {
try {
return fileURLToPath(file);
} catch {
return String(file);
}
}

function mkMissingFileUrl(file) {
try {
const url = new URL(String(file));

if (url.protocol === "file:") {
// Importing this URL fails, then Mocha retries require(originalFile), which pirates transforms.
url.pathname += REPL_INSTRUMENTATION_MISSING_FILE_SUFFIX;

return url.toString();
}
} catch {
// If it is not a URL, fall through and make the module path unresolved.
}

return String(file) + REPL_INSTRUMENTATION_MISSING_FILE_SUFFIX;
}

function initBuildContext(outBus) {
outBus.emit(TestReaderEvents.NEW_BUILD_INSTRUCTION, ctx => {
ctx.treeBuilder = TreeBuilderDecorator.create(ctx.treeBuilder);
Expand Down Expand Up @@ -111,7 +170,10 @@ function passthroughFileEvents(inBus, outBus) {
[MochaEventBus.events.EVENT_FILE_PRE_REQUIRE, MasterEvents.BEFORE_FILE_READ],
[MochaEventBus.events.EVENT_FILE_POST_REQUIRE, MasterEvents.AFTER_FILE_READ],
].forEach(([mochaEvent, ourEvent]) => {
inBus.on(mochaEvent, (ctx, file) => outBus.emit(ourEvent, { file }));
inBus.on(mochaEvent, (ctx, file) => {
debugReplInstrumentation("%s %s", mochaEvent, file);
outBus.emit(ourEvent, { file });
});
});
}

Expand Down
Loading
Loading