Simpler setup files
This commit is contained in:
parent
4373f0a285
commit
c843ba7c22
|
|
@ -39,7 +39,8 @@ Transcript-based shell integration test runner. Bun + TypeScript.
|
||||||
- `#` after a command = comment (stripped); `#` in expected output is literal
|
- `#` after a command = comment (stripped); `#` in expected output is literal
|
||||||
- `@env KEY=VALUE` before first command = set environment variable
|
- `@env KEY=VALUE` before first command = set environment variable
|
||||||
- `@setup path.shout` before first command = prepend commands (and `@env`) from another file
|
- `@setup path.shout` before first command = prepend commands (and `@env`) from another file
|
||||||
- Setup files cannot themselves contain `@setup` (no nesting)
|
- Setup files use a plain format: each line is a command (no `$ ` prefix), `#` lines are comments, blank lines ignored
|
||||||
|
- Setup files can contain `@env` directives but not `@setup` (no nesting)
|
||||||
- User file `@env` overrides setup file `@env`
|
- User file `@env` overrides setup file `@env`
|
||||||
- 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
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { resolve, relative, dirname } from "node:path"
|
||||||
import { program } from "commander"
|
import { program } from "commander"
|
||||||
import ansis from "ansis"
|
import ansis from "ansis"
|
||||||
|
|
||||||
import { parse, type Command, type ShoutFile } from "../parse.ts"
|
import { parse, parseSetup, type Command, type ShoutFile } from "../parse.ts"
|
||||||
import { runFile, cleanupTmpDir } from "../run.ts"
|
import { runFile, cleanupTmpDir } from "../run.ts"
|
||||||
import { evaluateFile, formatFailure, formatSummary } from "../format.ts"
|
import { evaluateFile, formatFailure, formatSummary } from "../format.ts"
|
||||||
import type { TestResult } from "../format.ts"
|
import type { TestResult } from "../format.ts"
|
||||||
|
|
@ -115,11 +115,8 @@ program
|
||||||
if (d.type === "setup") {
|
if (d.type === "setup") {
|
||||||
const setupPath = resolve(dirname(filePath), d.path)
|
const setupPath = resolve(dirname(filePath), d.path)
|
||||||
const setupContent = await readFile(setupPath, "utf-8")
|
const setupContent = await readFile(setupPath, "utf-8")
|
||||||
const setupParsed = parse(relative(cwd, setupPath), setupContent)
|
const setupParsed = parseSetup(relative(cwd, setupPath), setupContent)
|
||||||
for (const sd of setupParsed.directives) {
|
for (const sd of setupParsed.directives) {
|
||||||
if (sd.type === "setup") {
|
|
||||||
throw new Error(`${relative(cwd, setupPath)}: @setup not allowed in setup files`)
|
|
||||||
}
|
|
||||||
if (sd.type === "env") setupEnvVars[sd.key] = sd.value
|
if (sd.type === "env") setupEnvVars[sd.key] = sd.value
|
||||||
}
|
}
|
||||||
setupCommands.push(...setupParsed.commands)
|
setupCommands.push(...setupParsed.commands)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ export type { CommandResult, FileResult } from "./run.ts"
|
||||||
export type { DiffLine } from "./match.ts"
|
export type { DiffLine } from "./match.ts"
|
||||||
export type { TestResult } from "./format.ts"
|
export type { TestResult } from "./format.ts"
|
||||||
|
|
||||||
export { parse } from "./parse.ts"
|
export { parse, parseSetup } from "./parse.ts"
|
||||||
export { runFile, cleanupTmpDir } from "./run.ts"
|
export { runFile, cleanupTmpDir } from "./run.ts"
|
||||||
export { matchLine, matchOutput, diff } from "./match.ts"
|
export { matchLine, matchOutput, diff } from "./match.ts"
|
||||||
export { evaluateFile, formatFailure, formatSummary } from "./format.ts"
|
export { evaluateFile, formatFailure, formatSummary } from "./format.ts"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import { parse } from "./parse.ts"
|
import { parse, parseSetup } from "./parse.ts"
|
||||||
|
|
||||||
describe("parse", () => {
|
describe("parse", () => {
|
||||||
test("simple command with output", () => {
|
test("simple command with output", () => {
|
||||||
|
|
@ -116,3 +116,44 @@ describe("parse", () => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("parseSetup", () => {
|
||||||
|
test("plain commands without $ prefix", () => {
|
||||||
|
const result = parseSetup("setup.shout", "export FOO=bar\necho hello\n")
|
||||||
|
expect(result.commands).toHaveLength(2)
|
||||||
|
expect(result.commands[0]!.command).toBe("export FOO=bar")
|
||||||
|
expect(result.commands[1]!.command).toBe("echo hello")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("@env directives", () => {
|
||||||
|
const result = parseSetup("setup.shout", "@env PORT=3000\nexport FOO=bar\n")
|
||||||
|
expect(result.directives).toEqual([
|
||||||
|
{ type: "env", key: "PORT", value: "3000", line: 1 },
|
||||||
|
])
|
||||||
|
expect(result.commands).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("blank lines and comments are ignored", () => {
|
||||||
|
const result = parseSetup("setup.shout", "# set up env\nexport FOO=bar\n\nexport BAZ=qux\n")
|
||||||
|
expect(result.commands).toHaveLength(2)
|
||||||
|
expect(result.commands[0]!.command).toBe("export FOO=bar")
|
||||||
|
expect(result.commands[1]!.command).toBe("export BAZ=qux")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("strips trailing comments from commands", () => {
|
||||||
|
const result = parseSetup("setup.shout", "export FOO=bar # set foo\n")
|
||||||
|
expect(result.commands[0]!.command).toBe("export FOO=bar")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("@setup in setup file throws", () => {
|
||||||
|
expect(() => parseSetup("setup.shout", "@setup other.shout\n")).toThrow(
|
||||||
|
"setup.shout:1: @setup not allowed in setup files",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("commands have no expected output", () => {
|
||||||
|
const result = parseSetup("setup.shout", "echo hello\n")
|
||||||
|
expect(result.commands[0]!.expected).toEqual([])
|
||||||
|
expect(result.commands[0]!.exitCode).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
56
src/parse.ts
56
src/parse.ts
|
|
@ -54,6 +54,54 @@ function trimTrailingEmpty(lines: string[]): string[] {
|
||||||
return lines.slice(0, end)
|
return lines.slice(0, end)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseEnvDirective(path: string, line: string, lineNum: number): { key: string; value: string } {
|
||||||
|
const rest = line.slice(5).trim()
|
||||||
|
const eq = rest.indexOf("=")
|
||||||
|
if (eq <= 0) {
|
||||||
|
throw new Error(`${path}:${lineNum}: malformed @env directive (expected KEY=VALUE): ${line}`)
|
||||||
|
}
|
||||||
|
return { key: rest.slice(0, eq), value: rest.slice(eq + 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSetup(path: string, content: string): ShoutFile {
|
||||||
|
const rawLines = content.split("\n")
|
||||||
|
|
||||||
|
if (rawLines.length > 0 && rawLines[rawLines.length - 1] === "") {
|
||||||
|
rawLines.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const commands: Command[] = []
|
||||||
|
const directives: Directive[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < rawLines.length; i++) {
|
||||||
|
const line = rawLines[i]!
|
||||||
|
|
||||||
|
if (line === "" || line.startsWith("#")) continue
|
||||||
|
|
||||||
|
if (line.startsWith("@")) {
|
||||||
|
if (line.startsWith("@env ")) {
|
||||||
|
const { key, value } = parseEnvDirective(path, line, i + 1)
|
||||||
|
directives.push({ type: "env", key, value, line: i + 1 })
|
||||||
|
} else if (line.startsWith("@setup ")) {
|
||||||
|
throw new Error(`${path}:${i + 1}: @setup not allowed in setup files`)
|
||||||
|
} else {
|
||||||
|
throw new Error(`${path}:${i + 1}: unknown directive: ${line}`)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
commands.push({
|
||||||
|
line: i + 1,
|
||||||
|
raw: line,
|
||||||
|
command: stripComment(line),
|
||||||
|
expected: [],
|
||||||
|
exitCode: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { path, commands, directives }
|
||||||
|
}
|
||||||
|
|
||||||
export function parse(path: string, content: string): ShoutFile {
|
export function parse(path: string, content: string): ShoutFile {
|
||||||
const rawLines = content.split("\n")
|
const rawLines = content.split("\n")
|
||||||
|
|
||||||
|
|
@ -78,12 +126,8 @@ export function parse(path: string, content: string): ShoutFile {
|
||||||
}
|
}
|
||||||
directives.push({ type: "setup", path: setupPath, line: i + 1 })
|
directives.push({ type: "setup", path: setupPath, line: i + 1 })
|
||||||
} else if (line.startsWith("@env ")) {
|
} else if (line.startsWith("@env ")) {
|
||||||
const rest = line.slice(5).trim()
|
const { key, value } = parseEnvDirective(path, line, i + 1)
|
||||||
const eq = rest.indexOf("=")
|
directives.push({ type: "env", key, value, line: i + 1 })
|
||||||
if (eq <= 0) {
|
|
||||||
throw new Error(`${path}:${i + 1}: malformed @env directive (expected KEY=VALUE): ${line}`)
|
|
||||||
}
|
|
||||||
directives.push({ type: "env", key: rest.slice(0, eq), value: rest.slice(eq + 1), line: i + 1 })
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`${path}:${i + 1}: unknown directive: ${line}`)
|
throw new Error(`${path}:${i + 1}: unknown directive: ${line}`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { ShoutFile } from "./parse.ts"
|
||||||
function makeFile(commands: { command: string; expected?: string[] }[]): ShoutFile {
|
function makeFile(commands: { command: string; expected?: string[] }[]): ShoutFile {
|
||||||
return {
|
return {
|
||||||
path: "test.shout",
|
path: "test.shout",
|
||||||
|
directives: [],
|
||||||
commands: commands.map((c, i) => ({
|
commands: commands.map((c, i) => ({
|
||||||
line: i + 1,
|
line: i + 1,
|
||||||
raw: `$ ${c.command}`,
|
raw: `$ ${c.command}`,
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
$ export READY=yes
|
export READY=yes
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user