Add @env, @setup directives and --port-from flag
This commit is contained in:
parent
56d982db17
commit
70008d16b9
|
|
@ -1,11 +1,11 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import { readdir, readFile, writeFile } from "node:fs/promises"
|
||||
import { resolve, relative } from "node:path"
|
||||
import { resolve, relative, dirname } from "node:path"
|
||||
import { program } from "commander"
|
||||
import ansis from "ansis"
|
||||
|
||||
import { parse } from "../parse.ts"
|
||||
import { parse, type ShoutFile } from "../parse.ts"
|
||||
import { runFile, cleanupTmpDir } from "../run.ts"
|
||||
import { evaluateFile, formatFailure, formatSummary } from "../format.ts"
|
||||
import type { TestResult } from "../format.ts"
|
||||
|
|
@ -53,6 +53,7 @@ program
|
|||
.option("--path <path>", "Prepend <path> to PATH (repeatable)", (val: string, acc: string[]) => [...acc, val])
|
||||
.option("--timeout <dur>", "Per-command timeout", "10s")
|
||||
.option("-v, --verbose", "Print each command as it runs")
|
||||
.option("--port-from <n>", "Auto-assign $PORT starting from <n>")
|
||||
.option("--parallel", "Run files in parallel")
|
||||
.option("--example", "Print an example .shout file and exit")
|
||||
.action(async (fileArgs: string[], opts) => {
|
||||
|
|
@ -88,14 +89,40 @@ $ true
|
|||
const start = performance.now()
|
||||
const results: TestResult[] = []
|
||||
const cwd = process.cwd()
|
||||
const portFrom = opts.portFrom ? parseInt(opts.portFrom, 10) : undefined
|
||||
let nextPort = portFrom ?? 0
|
||||
|
||||
const runOne = async (filePath: string) => {
|
||||
const runOne = async (filePath: string, port?: number) => {
|
||||
const content = await readFile(filePath, "utf-8")
|
||||
const parsed = parse(relative(cwd, filePath), content)
|
||||
|
||||
const fileResult = await runFile(parsed, {
|
||||
// Collect env vars: --port-from, then @env directives
|
||||
const envVars: Record<string, string> = {}
|
||||
if (port !== undefined) envVars["PORT"] = String(port)
|
||||
for (const d of parsed.directives) {
|
||||
if (d.type === "env") envVars[d.key] = d.value
|
||||
}
|
||||
|
||||
// Resolve @setup directives: prepend setup commands
|
||||
let setupCount = 0
|
||||
let merged: ShoutFile = parsed
|
||||
const setupDirectives = parsed.directives.filter(d => d.type === "setup")
|
||||
if (setupDirectives.length > 0) {
|
||||
const setupCommands = []
|
||||
for (const d of setupDirectives) {
|
||||
const setupPath = resolve(dirname(filePath), d.path)
|
||||
const setupContent = await readFile(setupPath, "utf-8")
|
||||
const setupParsed = parse(relative(cwd, setupPath), setupContent)
|
||||
setupCommands.push(...setupParsed.commands)
|
||||
}
|
||||
setupCount = setupCommands.length
|
||||
merged = { ...parsed, commands: [...setupCommands, ...parsed.commands] }
|
||||
}
|
||||
|
||||
const fileResult = await runFile(merged, {
|
||||
cleanEnv: opts.cleanEnv ?? false,
|
||||
pathDirs: opts.path,
|
||||
envVars: Object.keys(envVars).length > 0 ? envVars : undefined,
|
||||
timeout: timeoutMs,
|
||||
verbose: opts.verbose ?? false,
|
||||
onCommand: opts.verbose
|
||||
|
|
@ -110,7 +137,9 @@ $ true
|
|||
)
|
||||
|
||||
if (opts.update && fileResult.results.length > 0) {
|
||||
const updated = rewriteFile(parsed, fileResult.results, content)
|
||||
// Only rewrite with the file's own results, not setup results
|
||||
const fileOwnResults = fileResult.results.slice(setupCount)
|
||||
const updated = rewriteFile(parsed, fileOwnResults, content)
|
||||
if (updated !== content) {
|
||||
await writeFile(filePath, updated)
|
||||
}
|
||||
|
|
@ -140,7 +169,11 @@ $ true
|
|||
}
|
||||
|
||||
if (opts.parallel) {
|
||||
const all = await Promise.all(files.map(runOne))
|
||||
const tasks = files.map(f => {
|
||||
const port = portFrom !== undefined ? nextPort++ : undefined
|
||||
return runOne(f, port)
|
||||
})
|
||||
const all = await Promise.all(tasks)
|
||||
for (const r of all) {
|
||||
printDots(r)
|
||||
results.push(r)
|
||||
|
|
@ -148,7 +181,8 @@ $ true
|
|||
process.stdout.write("\n")
|
||||
} else {
|
||||
for (const filePath of files) {
|
||||
const r = await runOne(filePath)
|
||||
const port = portFrom !== undefined ? nextPort++ : undefined
|
||||
const r = await runOne(filePath, port)
|
||||
printDots(r)
|
||||
results.push(r)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export type { Command, ShoutFile } from "./parse.ts"
|
||||
export type { Command, Directive, ShoutFile } from "./parse.ts"
|
||||
export type { CommandResult, FileResult } from "./run.ts"
|
||||
export type { DiffLine } from "./match.ts"
|
||||
export type { TestResult } from "./format.ts"
|
||||
|
|
|
|||
|
|
@ -66,4 +66,47 @@ describe("parse", () => {
|
|||
const b = parse("test.shout", "$ echo hi\nhi")
|
||||
expect(a.commands[0]!.expected).toEqual(b.commands[0]!.expected)
|
||||
})
|
||||
|
||||
test("@env directive", () => {
|
||||
const result = parse("test.shout", "@env PORT=3000\n$ echo $PORT\n3000\n")
|
||||
expect(result.directives).toEqual([
|
||||
{ type: "env", key: "PORT", value: "3000", line: 1 },
|
||||
])
|
||||
expect(result.commands).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("@env with value containing =", () => {
|
||||
const result = parse("test.shout", "@env FOO=bar=baz\n$ echo $FOO\n")
|
||||
expect(result.directives[0]).toEqual(
|
||||
{ type: "env", key: "FOO", value: "bar=baz", line: 1 },
|
||||
)
|
||||
})
|
||||
|
||||
test("@setup directive", () => {
|
||||
const result = parse("test.shout", "@setup shared/setup.shout\n$ echo hi\nhi\n")
|
||||
expect(result.directives).toEqual([
|
||||
{ type: "setup", path: "shared/setup.shout", line: 1 },
|
||||
])
|
||||
expect(result.commands).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("multiple directives", () => {
|
||||
const content = "@setup setup.shout\n@env PORT=3000\n@env NODE_ENV=test\n\n$ echo hi\nhi\n"
|
||||
const result = parse("test.shout", content)
|
||||
expect(result.directives).toHaveLength(3)
|
||||
expect(result.directives[0]!.type).toBe("setup")
|
||||
expect(result.directives[1]).toEqual({ type: "env", key: "PORT", value: "3000", line: 2 })
|
||||
expect(result.directives[2]).toEqual({ type: "env", key: "NODE_ENV", value: "test", line: 3 })
|
||||
})
|
||||
|
||||
test("@ lines after first command are expected output", () => {
|
||||
const result = parse("test.shout", "$ cat config\n@env PORT=3000\n")
|
||||
expect(result.directives).toEqual([])
|
||||
expect(result.commands[0]!.expected).toEqual(["@env PORT=3000"])
|
||||
})
|
||||
|
||||
test("no directives returns empty array", () => {
|
||||
const result = parse("test.shout", "$ echo hi\nhi\n")
|
||||
expect(result.directives).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
23
src/parse.ts
23
src/parse.ts
|
|
@ -6,9 +6,14 @@ export type Command = {
|
|||
exitCode: number | "*" | null
|
||||
}
|
||||
|
||||
export type Directive =
|
||||
| { type: "setup"; path: string; line: number }
|
||||
| { type: "env"; key: string; value: string; line: number }
|
||||
|
||||
export type ShoutFile = {
|
||||
path: string
|
||||
commands: Command[]
|
||||
directives: Directive[]
|
||||
}
|
||||
|
||||
function stripComment(line: string): string {
|
||||
|
|
@ -58,12 +63,28 @@ export function parse(path: string, content: string): ShoutFile {
|
|||
}
|
||||
|
||||
const commands: Command[] = []
|
||||
const directives: Directive[] = []
|
||||
let current: Command | null = null
|
||||
let seenCommand = false
|
||||
|
||||
for (let i = 0; i < rawLines.length; i++) {
|
||||
const line = rawLines[i]!
|
||||
|
||||
if (!seenCommand && line.startsWith("@")) {
|
||||
if (line.startsWith("@setup ")) {
|
||||
directives.push({ type: "setup", path: line.slice(7).trim(), line: i + 1 })
|
||||
} else if (line.startsWith("@env ")) {
|
||||
const rest = line.slice(5).trim()
|
||||
const eq = rest.indexOf("=")
|
||||
if (eq > 0) {
|
||||
directives.push({ type: "env", key: rest.slice(0, eq), value: rest.slice(eq + 1), line: i + 1 })
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith("$ ")) {
|
||||
seenCommand = true
|
||||
if (current) {
|
||||
const trimmed = trimTrailingEmpty(current.expected)
|
||||
const { lines: expectedLines, exitCode } = parseExitCode(trimmed)
|
||||
|
|
@ -92,5 +113,5 @@ export function parse(path: string, content: string): ShoutFile {
|
|||
commands.push(current)
|
||||
}
|
||||
|
||||
return { path, commands }
|
||||
return { path, commands, directives }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export type FileResult = {
|
|||
type RunOptions = {
|
||||
cleanEnv: boolean
|
||||
pathDirs?: string[]
|
||||
envVars?: Record<string, string>
|
||||
timeout: number
|
||||
verbose: boolean
|
||||
onCommand?: (cmd: Command) => void
|
||||
|
|
@ -128,6 +129,10 @@ export async function runFile(
|
|||
env["HOME"] = tmpDir
|
||||
env["CUE_DIR"] = tmpDir
|
||||
|
||||
if (options.envVars) {
|
||||
Object.assign(env, options.envVars)
|
||||
}
|
||||
|
||||
if (options.pathDirs?.length) {
|
||||
env["PATH"] = options.pathDirs.join(":") + ":" + (env["PATH"] ?? "")
|
||||
}
|
||||
|
|
|
|||
5
test/env.shout
Normal file
5
test/env.shout
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
@env GREETING=hello
|
||||
@env TARGET=world
|
||||
|
||||
$ echo "$GREETING $TARGET"
|
||||
hello world
|
||||
1
test/setup-shared.shout
Normal file
1
test/setup-shared.shout
Normal file
|
|
@ -0,0 +1 @@
|
|||
$ export READY=yes
|
||||
4
test/setup-user.shout
Normal file
4
test/setup-user.shout
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
@setup setup-shared.shout
|
||||
|
||||
$ echo $READY
|
||||
yes
|
||||
Loading…
Reference in New Issue
Block a user