import { ScriptLine, Shell } from "./shells/types.js" import { execa, type ExecaChildProcess } from "execa" import testTmpDir from "./test-tmp-dir.js" import { Writable } from "node:stream" import { dedent } from "ts-dedent" import testCwd from "./test-cwd.js" import path, { join } from "node:path" import { writeFile } from "node:fs/promises" import chalk from "chalk" import testBinDir from "./test-bin-dir.js" import { rmSync } from "node:fs" class Script { constructor( private readonly config: { fnmDir: string }, private readonly lines: ScriptLine[], private readonly extraEnvVars: Record = {} ) {} then(line: ScriptLine): Script { return new Script(this.config, [...this.lines, line]) } takeSnapshot(shell: Pick): this { const script = this.lines.join("\n") expect(script).toMatchSnapshot(shell.name()) return this } addExtraEnvVar(name: string, value: string): this { return new Script( this.config, this.lines, Object.assign({}, this.extraEnvVars, { [name]: value }) ) as this } async execute( shell: Pick< Shell, "binaryName" | "launchArgs" | "currentlySupported" | "forceFile" > ): Promise { if (!shell.currentlySupported()) { return } const args = [...shell.launchArgs()] if (shell.forceFile) { let filename = join(testTmpDir(), "script") if (typeof shell.forceFile === "string") { filename = filename + shell.forceFile } await writeFile(filename, [...this.lines, "exit 0"].join("\n")) args.push(filename) } const child = execa(shell.binaryName(), args, { stdio: [shell.forceFile ? "ignore" : "pipe", "pipe", "pipe"], cwd: testCwd(), env: (() => { const newProcessEnv: Record = { ...this.extraEnvVars, ...removeAllFnmEnvVars(process.env), PATH: [testBinDir(), fnmTargetDir(), process.env.PATH] .filter(Boolean) .join(path.delimiter), FNM_DIR: this.config.fnmDir, } delete newProcessEnv.NODE_OPTIONS return newProcessEnv })(), extendEnv: false, reject: false, }) if (child.stdin) { const childStdin = child.stdin for (const line of this.lines) { await write(childStdin, `${line}\n`) } await write(childStdin, "exit 0\n") } const { stdout, stderr } = streamOutputsAndBuffer(child) const finished = await child if (finished.failed) { console.error( dedent` Script failed. code ${finished.exitCode} signal ${finished.signal} stdout: ${padAllLines(stdout.join(""), 2)} stderr: ${padAllLines(stderr.join(""), 2)} ` ) throw new Error( `Script failed on ${testCwd()} with code ${finished.exitCode}` ) } } asLine(): ScriptLine { return this.lines.join("\n") } } function streamOutputsAndBuffer(child: ExecaChildProcess) { const stdout: string[] = [] const stderr: string[] = [] const testName = expect.getState().currentTestName ?? "unknown" const testPath = expect.getState().testPath ?? "unknown" const stdoutPrefix = chalk.cyan.dim(`[stdout] ${testPath}/${testName}: `) const stderrPrefix = chalk.magenta.dim(`[stderr] ${testPath}/${testName}: `) if (child.stdout) { child.stdout.on("data", (data) => { const lines = data.toString().trim().split(/\r?\n/) for (const line of lines) { process.stdout.write(`${stdoutPrefix}${line}\n`) } stdout.push(data.toString()) }) } if (child.stderr) { child.stderr.on("data", (data) => { const lines = data.toString().trim().split(/\r?\n/) for (const line of lines) { process.stdout.write(`${stderrPrefix}${line}\n`) } stderr.push(data.toString()) }) } return { stdout, stderr } } function padAllLines(text: string, padding: number): string { return text .split("\n") .map((line) => " ".repeat(padding) + line) .join("\n") } function write(writable: Writable, text: string): Promise { return new Promise((resolve, reject) => { writable.write(text, (err) => { if (err) return reject(err) return resolve() }) }) } export function script(shell: Pick): Script { const fnmDir = path.join(testTmpDir(), "fnm") rmSync(join(fnmDir, "aliases"), { recursive: true, force: true }) return new Script({ fnmDir }, shell.dieOnErrors ? [shell.dieOnErrors()] : []) } function removeAllFnmEnvVars(obj: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const result: NodeJS.ProcessEnv = {} for (const [key, value] of Object.entries(obj)) { if (!key.startsWith("FNM_")) { result[key] = value } } return result } function fnmTargetDir(): string { return path.join( process.cwd(), "target", process.env.FNM_TARGET_NAME ?? "debug" ) }