Compare commits

...

6 Commits

5 changed files with 46 additions and 10 deletions

View File

@ -13,7 +13,7 @@ Transcript-based shell integration test runner. Bun + TypeScript.
- `--path <path>` — prepend to `$PATH` (repeatable) - `--path <path>` — prepend to `$PATH` (repeatable)
- `--timeout <dur>` — per-command timeout (default `10s`) - `--timeout <dur>` — per-command timeout (default `10s`)
- `-v, --verbose` — print each command as it runs - `-v, --verbose` — print each command as it runs
- `--port-from <n>` — auto-assign `$PORT` starting from n - `--port-from <n>` — auto-assign `$PORT` starting from n (default `5400`)
- `--parallel` — run files in parallel - `--parallel` — run files in parallel
## Architecture ## Architecture
@ -45,6 +45,8 @@ Transcript-based shell integration test runner. Bun + TypeScript.
- Setup command failures abort the test with an error - Setup command failures abort the test with an error
- Each file runs in a fresh temp dir with a single `/bin/sh` session - Each file runs in a fresh temp dir with a single `/bin/sh` session
- `$HOME` and `$SHOUT_DIR` are set to the temp dir automatically - `$HOME` and `$SHOUT_DIR` are set to the temp dir automatically
- `$SHOUT_SOURCE_DIR` is set to the directory containing the `.shout` file
- `$SHOUT_PROJECT_DIR` is set to `cwd` where `shout` was invoked
- stdout and stderr are merged (`exec 2>&1`) - stdout and stderr are merged (`exec 2>&1`)
## Style ## Style

View File

@ -89,6 +89,28 @@ Options:
-h, --help display help for command -h, --help display help for command
``` ```
## Environment Variables
### Set automatically
| Variable | Value |
|---|---|
| `HOME` | Path to the temp directory created for the test |
| `SHOUT_DIR` | Same as `HOME` — the temp directory for the test |
| `PORT` | Auto-assigned when `--port-from <n>` is used (increments per file). Not set if `PORT` is already defined via `@env` or `@setup`. |
### Inherited
By default, the test shell inherits all environment variables from the parent process. Use `--clean-env` to start with an empty environment instead.
### Modified
`PATH` is prepended with any directories passed via `--path <path>`.
### User-defined
Use `@env KEY=VALUE` directives to set arbitrary variables. See [Directives](#directives).
Print an example `.shout` file: Print an example `.shout` file:
``` ```

View File

@ -79,7 +79,7 @@ program
.option("--path <path>", "Prepend <path> to PATH (repeatable)", (val: string, acc: string[]) => [...acc, val], []) .option("--path <path>", "Prepend <path> to PATH (repeatable)", (val: string, acc: string[]) => [...acc, val], [])
.option("--timeout <dur>", "Per-command timeout", "10s") .option("--timeout <dur>", "Per-command timeout", "10s")
.option("-v, --verbose", "Print each command as it runs") .option("-v, --verbose", "Print each command as it runs")
.option("--port-from <n>", "Auto-assign $PORT starting from <n>") .option("--port-from <n>", "Auto-assign $PORT starting from <n>", "5400")
.option("--parallel", "Run files in parallel") .option("--parallel", "Run files in parallel")
.action(async (fileArgs: string[], opts) => { .action(async (fileArgs: string[], opts) => {
const timeoutMs = parseDuration(opts.timeout) const timeoutMs = parseDuration(opts.timeout)
@ -94,14 +94,14 @@ program
const start = performance.now() const start = performance.now()
const results: TestResult[] = [] const results: TestResult[] = []
const cwd = process.cwd() const cwd = process.cwd()
const portFrom = opts.portFrom ? parseInt(opts.portFrom, 10) : undefined const portFrom = parseInt(opts.portFrom, 10)
if (portFrom !== undefined && Number.isNaN(portFrom)) { if (Number.isNaN(portFrom)) {
console.error("--port-from must be an integer") console.error("--port-from must be an integer")
process.exit(1) process.exit(1)
} }
let nextPort = portFrom let nextPort = portFrom
const runOne = async (filePath: string, port: number | undefined) => { const runOne = async (filePath: string, port: number) => {
const content = await readFile(filePath, "utf-8") const content = await readFile(filePath, "utf-8")
const parsed = parse(relative(cwd, filePath), content) const parsed = parse(relative(cwd, filePath), content)
@ -125,7 +125,7 @@ program
} }
} }
Object.assign(envVars, setupEnvVars, userEnvVars) Object.assign(envVars, setupEnvVars, userEnvVars)
if (port !== undefined && !("PORT" in userEnvVars) && !("PORT" in setupEnvVars)) { if (!("PORT" in userEnvVars) && !("PORT" in setupEnvVars)) {
envVars["PORT"] = String(port) envVars["PORT"] = String(port)
} }
const merged: ShoutFile = { ...parsed, commands: [...setupCommands, ...parsed.commands] } const merged: ShoutFile = { ...parsed, commands: [...setupCommands, ...parsed.commands] }
@ -134,6 +134,8 @@ program
cleanEnv: opts.cleanEnv ?? false, cleanEnv: opts.cleanEnv ?? false,
pathDirs: opts.path, pathDirs: opts.path,
envVars, envVars,
sourceDir: resolve(dirname(filePath)),
projectDir: cwd,
timeout: timeoutMs, timeout: timeoutMs,
verbose: opts.verbose ?? false, verbose: opts.verbose ?? false,
onCommand: opts.verbose onCommand: opts.verbose
@ -203,7 +205,7 @@ program
} }
if (opts.parallel) { if (opts.parallel) {
const all = await Promise.all(files.map(f => runOne(f, nextPort !== undefined ? nextPort++ : undefined))) const all = await Promise.all(files.map(f => runOne(f, nextPort++)))
for (const r of all) { for (const r of all) {
printDots(r) printDots(r)
results.push(r) results.push(r)
@ -211,7 +213,7 @@ program
process.stdout.write("\n") process.stdout.write("\n")
} else { } else {
for (const filePath of files) { for (const filePath of files) {
const r = await runOne(filePath, nextPort !== undefined ? nextPort++ : undefined) const r = await runOne(filePath, nextPort++)
printDots(r) printDots(r)
results.push(r) results.push(r)
} }

View File

@ -21,6 +21,8 @@ type RunOptions = {
cleanEnv: boolean cleanEnv: boolean
pathDirs?: string[] pathDirs?: string[]
envVars?: Record<string, string> envVars?: Record<string, string>
sourceDir?: string
projectDir?: string
timeout: number timeout: number
verbose: boolean verbose: boolean
onCommand?: (cmd: Command) => void onCommand?: (cmd: Command) => void
@ -128,6 +130,12 @@ export async function runFile(
env["HOME"] = tmpDir env["HOME"] = tmpDir
env["SHOUT_DIR"] = tmpDir env["SHOUT_DIR"] = tmpDir
if (options.sourceDir) {
env["SHOUT_SOURCE_DIR"] = options.sourceDir
}
if (options.projectDir) {
env["SHOUT_PROJECT_DIR"] = options.projectDir
}
if (options.envVars) { if (options.envVars) {
Object.assign(env, options.envVars) Object.assign(env, options.envVars)

View File

@ -238,6 +238,8 @@ Options:
<p>Shout sets these variables before running your commands:</p> <p>Shout sets these variables before running your commands:</p>
<pre><code><span class="bright">HOME</span> <span class="dim"></span> <span class="output">temp directory for this test file</span> <pre><code><span class="bright">HOME</span> <span class="dim"></span> <span class="output">temp directory for this test file</span>
<span class="bright">SHOUT_DIR</span> <span class="dim"></span> <span class="output">same temp directory</span> <span class="bright">SHOUT_DIR</span> <span class="dim"></span> <span class="output">same temp directory</span>
<span class="bright">SHOUT_SOURCE_DIR</span> <span class="dim"></span> <span class="output">directory containing the .shout file</span>
<span class="bright">SHOUT_PROJECT_DIR</span> <span class="dim"></span> <span class="output">directory where shout was invoked</span>
<span class="bright">PATH</span> <span class="dim"></span> <span class="output">prepended with --path dirs, if any</span></code></pre> <span class="bright">PATH</span> <span class="dim"></span> <span class="output">prepended with --path dirs, if any</span></code></pre>
<p>Each file runs in its own temp directory. <code>--clean-env</code> starts with an empty environment instead of inheriting yours.</p> <p>Each file runs in its own temp directory. <code>--clean-env</code> starts with an empty environment instead of inheriting yours.</p>
</section> </section>