|
|
|
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<string, string> = {}
|
|
|
|
) {}
|
|
|
|
then(line: ScriptLine): Script {
|
|
|
|
return new Script(this.config, [...this.lines, line])
|
|
|
|
}
|
|
|
|
|
|
|
|
takeSnapshot(shell: Pick<Shell, "name">): 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<void> {
|
|
|
|
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<string, string> = {
|
|
|
|
...this.extraEnvVars,
|
|
|
|
...removeAllFnmEnvVars(process.env),
|
|
|
|
PATH: [testBinDir(), fnmTargetDir(), process.env.PATH]
|
|
|
|
.filter(Boolean)
|
|
|
|
.join(path.delimiter),
|
|
|
|
FNM_DIR: this.config.fnmDir,
|
|
|
|
FNM_NODE_DIST_MIRROR: "http://localhost:8080",
|
|
|
|
}
|
|
|
|
|
|
|
|
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<void> {
|
|
|
|
return new Promise<void>((resolve, reject) => {
|
|
|
|
writable.write(text, (err) => {
|
|
|
|
if (err) return reject(err)
|
|
|
|
return resolve()
|
|
|
|
})
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
export function script(shell: Pick<Shell, "dieOnErrors">): 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"
|
|
|
|
)
|
|
|
|
}
|