Move tests, remove old .ts files
This commit is contained in:
parent
eef0f4d2dc
commit
aba7f9676d
|
|
@ -66,14 +66,14 @@ Transcript-based shell integration test runner. Bun + TypeScript.
|
|||
1. `src/parse.ts` — update types (`Directive`, `ShoutFile`, `Command`) and both parsers (`parse` + `parseSetup`)
|
||||
2. `src/parse.test.ts` — unit tests for parsing the new syntax in both `.shout` and setup file contexts
|
||||
3. `src/cli/index.ts` — wire up the parsed result in `runOne` (directive resolution, command merging, result handling)
|
||||
4. `test/*.shout` — integration test file exercising the feature end-to-end
|
||||
4. `tests/*.shout` — integration test file exercising the feature end-to-end
|
||||
5. `CLAUDE.md` — update `.shout file format` section
|
||||
6. `README.md` — update Directives section
|
||||
7. `web/index.html` — add or update a section on the website
|
||||
8. Run `bun test` and `bun run src/cli/index.ts test test/` to verify
|
||||
8. Run `cargo test` to verify
|
||||
|
||||
## Style
|
||||
|
||||
- Strict TypeScript, Bun runtime
|
||||
- No classes — plain functions and types
|
||||
- Tests in `src/*.test.ts`, example `.shout` files in `test/`
|
||||
- Integration tests in `tests/shout.rs`, example `.shout` files in `tests/`
|
||||
|
|
|
|||
31
package.json
31
package.json
|
|
@ -1,31 +0,0 @@
|
|||
{
|
||||
"name": "@because/shout",
|
||||
"version": "0.0.19",
|
||||
"description": "shell output tester",
|
||||
"module": "src/index.ts",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"bin": {
|
||||
"shout": "src/cli/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"check": "bunx tsc --noEmit",
|
||||
"cli:link": "ln -sf $(pwd)/src/cli/index.ts ~/.bun/bin/shout",
|
||||
"test": "bun test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/diff": "^8.0.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "14.0.3",
|
||||
"diff": "^8.0.3",
|
||||
"ansis": "*"
|
||||
}
|
||||
}
|
||||
333
src/cli/index.ts
333
src/cli/index.ts
|
|
@ -1,333 +0,0 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import { readdir, readFile, writeFile } from "node:fs/promises"
|
||||
import { resolve, relative, dirname } from "node:path"
|
||||
import { program } from "commander"
|
||||
import ansis from "ansis"
|
||||
|
||||
import { parse, parseSetup, type Command, type ShoutFile } from "../parse.ts"
|
||||
import { runFile, cleanupTmpDir, type CommandResult } from "../run.ts"
|
||||
import { evaluateFile, formatFailure, formatSummary } from "../format.ts"
|
||||
import type { TestResult } from "../format.ts"
|
||||
import { matchOutput } from "../match.ts"
|
||||
import { parseDuration } from "../duration.ts"
|
||||
import { rewriteFile } from "../update.ts"
|
||||
|
||||
async function filterGitignored(files: string[]): Promise<string[]> {
|
||||
if (files.length === 0) return files
|
||||
try {
|
||||
const proc = Bun.spawn(["git", "check-ignore", "--stdin"], {
|
||||
stdin: new Blob([files.join("\n")]),
|
||||
stdout: "pipe",
|
||||
stderr: "ignore",
|
||||
})
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
const ignored = new Set(output.trim().split("\n").filter(Boolean))
|
||||
return files.filter(f => !ignored.has(f))
|
||||
} catch {
|
||||
return files
|
||||
}
|
||||
}
|
||||
|
||||
async function findShoutFiles(paths: string[]): Promise<string[]> {
|
||||
const explicit: string[] = []
|
||||
const discovered: string[] = []
|
||||
|
||||
for (const p of paths) {
|
||||
const abs = resolve(p)
|
||||
const stat = await Bun.file(abs).exists()
|
||||
? Bun.file(abs)
|
||||
: null
|
||||
|
||||
if (stat && abs.endsWith(".shout")) {
|
||||
explicit.push(abs)
|
||||
continue
|
||||
}
|
||||
|
||||
// Try as directory
|
||||
try {
|
||||
const entries = await readdir(abs, { recursive: true })
|
||||
for (const entry of entries) {
|
||||
if (entry.endsWith(".shout")) {
|
||||
discovered.push(resolve(abs, entry))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If not a directory, try as file anyway
|
||||
if (abs.endsWith(".shout")) explicit.push(abs)
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = await filterGitignored(discovered)
|
||||
return [...explicit, ...filtered].sort()
|
||||
}
|
||||
|
||||
import pkg from "../../package.json"
|
||||
|
||||
program
|
||||
.name("shout")
|
||||
.description("$ shell output tester")
|
||||
.version(pkg.version)
|
||||
|
||||
program
|
||||
.command("test")
|
||||
.description("Run .shout test files")
|
||||
.argument("[files...]", "Files or directories to test")
|
||||
.option("-u, --update", "Rewrite expected output in-place with actual output")
|
||||
.option("-k, --keep", "Keep temp directories after run")
|
||||
.option("--clean-env", "Start with empty environment")
|
||||
.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>", "5400")
|
||||
.option("-t, --filter <pattern>", "Only run files matching <pattern> (substring match)")
|
||||
.option("--parallel", "Run files in parallel")
|
||||
.action(async (fileArgs: string[], opts) => {
|
||||
const timeoutMs = parseDuration(opts.timeout)
|
||||
const paths = fileArgs.length > 0 ? fileArgs : ["."]
|
||||
let files = await findShoutFiles(paths)
|
||||
|
||||
const start = performance.now()
|
||||
const results: TestResult[] = []
|
||||
const cwd = process.cwd()
|
||||
|
||||
if (opts.filter) {
|
||||
const pattern = opts.filter
|
||||
files = files.filter(f => relative(cwd, f).includes(pattern))
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
console.error(opts.filter ? `No .shout files matching "${opts.filter}"` : "No .shout files found")
|
||||
process.exit(1)
|
||||
}
|
||||
const portFrom = parseInt(opts.portFrom, 10)
|
||||
if (Number.isNaN(portFrom)) {
|
||||
console.error("--port-from must be an integer")
|
||||
process.exit(1)
|
||||
}
|
||||
let nextPort = portFrom
|
||||
|
||||
const runOne = async (filePath: string, port: number) => {
|
||||
const content = await readFile(filePath, "utf-8")
|
||||
const parsed = parse(relative(cwd, filePath), content)
|
||||
|
||||
// Resolve directives in a single pass. Setup @env is collected separately
|
||||
// so that the user file's @env always takes precedence.
|
||||
const envVars: Record<string, string> = {}
|
||||
const setupEnvVars: Record<string, string> = {}
|
||||
const userEnvVars: Record<string, string> = {}
|
||||
const setupMacros: Record<string, string> = {}
|
||||
const userMacros: Record<string, string> = {}
|
||||
const setupCommands: Command[] = []
|
||||
const teardownCommands: Command[] = [...parsed.teardownCommands]
|
||||
for (const d of parsed.directives) {
|
||||
if (d.type === "setup") {
|
||||
const setupPath = resolve(dirname(filePath), d.path)
|
||||
const setupContent = await readFile(setupPath, "utf-8")
|
||||
const setupParsed = parseSetup(relative(cwd, setupPath), setupContent)
|
||||
for (const sd of setupParsed.directives) {
|
||||
if (sd.type === "env") setupEnvVars[sd.key] = sd.value
|
||||
else if (sd.type === "def") setupMacros[sd.name] = sd.body
|
||||
}
|
||||
setupCommands.push(...setupParsed.commands)
|
||||
teardownCommands.push(...setupParsed.teardownCommands)
|
||||
} else if (d.type === "env") {
|
||||
userEnvVars[d.key] = d.value
|
||||
} else if (d.type === "def") {
|
||||
userMacros[d.name] = d.body
|
||||
}
|
||||
}
|
||||
Object.assign(envVars, setupEnvVars, userEnvVars)
|
||||
if (!("PORT" in userEnvVars) && !("PORT" in setupEnvVars)) {
|
||||
envVars["PORT"] = String(port)
|
||||
}
|
||||
const macros: Record<string, string> = Object.assign({}, setupMacros, userMacros)
|
||||
const expandMacro = (cmd: Command): Command => {
|
||||
const body = macros[cmd.command]
|
||||
return body !== undefined ? { ...cmd, command: body } : cmd
|
||||
}
|
||||
const merged: ShoutFile = {
|
||||
...parsed,
|
||||
commands: [...setupCommands, ...parsed.commands, ...teardownCommands].map(expandMacro),
|
||||
}
|
||||
|
||||
const setupLen = setupCommands.length
|
||||
const userLen = parsed.commands.length
|
||||
const printDot = (result: CommandResult) => {
|
||||
const { command, actual, exitCode } = result
|
||||
const outputMatches = matchOutput(command.expected, actual)
|
||||
let exitCodeMismatch = false
|
||||
if (command.exitCode === null) {
|
||||
exitCodeMismatch = exitCode !== 0
|
||||
} else if (command.exitCode === "*") {
|
||||
exitCodeMismatch = exitCode === 0
|
||||
} else {
|
||||
exitCodeMismatch = exitCode !== command.exitCode
|
||||
}
|
||||
if (outputMatches && !exitCodeMismatch) {
|
||||
process.stdout.write(ansis.green("."))
|
||||
} else {
|
||||
process.stdout.write(ansis.red("F"))
|
||||
}
|
||||
}
|
||||
|
||||
const fileResult = await runFile(merged, {
|
||||
cleanEnv: opts.cleanEnv ?? false,
|
||||
pathDirs: opts.path,
|
||||
envVars,
|
||||
sourceDir: resolve(dirname(filePath)),
|
||||
projectDir: cwd,
|
||||
timeout: timeoutMs,
|
||||
onCommand: opts.verbose
|
||||
? (cmd) => process.stderr.write(ansis.dim(` $ ${cmd.command}\n`))
|
||||
: undefined,
|
||||
onCommandResult: (index, result) => {
|
||||
if (index >= setupLen && index < setupLen + userLen) {
|
||||
printDot(result)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Check setup commands for failures
|
||||
for (let i = 0; i < setupCommands.length; i++) {
|
||||
const r = fileResult.results[i]
|
||||
const expected = setupCommands[i]!.exitCode
|
||||
const ok = expected === null
|
||||
? r?.exitCode === 0
|
||||
: expected === "*"
|
||||
? r?.exitCode !== 0
|
||||
: r?.exitCode === expected
|
||||
if (!ok) {
|
||||
if (opts.keep) {
|
||||
process.stderr.write(`${fileResult.tmpDir}\n`)
|
||||
} else {
|
||||
await cleanupTmpDir(fileResult.tmpDir)
|
||||
}
|
||||
return evaluateFile(
|
||||
parsed.path,
|
||||
[],
|
||||
`setup command failed (exit ${r?.exitCode ?? "?"}): $ ${setupCommands[i]!.command}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const fileOwnResults = fileResult.results.slice(
|
||||
setupCommands.length,
|
||||
setupCommands.length + parsed.commands.length,
|
||||
)
|
||||
|
||||
// Warn on teardown failures
|
||||
const teardownResults = fileResult.results.slice(setupCommands.length + parsed.commands.length)
|
||||
for (let i = 0; i < teardownResults.length; i++) {
|
||||
const r = teardownResults[i]
|
||||
if (r && r.exitCode !== 0) {
|
||||
process.stderr.write(
|
||||
ansis.yellow(`warning: teardown command failed (exit ${r.exitCode}): $ ${teardownCommands[i]!.command}\n`),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const testResult = evaluateFile(
|
||||
parsed.path,
|
||||
fileOwnResults,
|
||||
fileResult.error,
|
||||
)
|
||||
|
||||
if (opts.update && fileOwnResults.length > 0) {
|
||||
const updated = rewriteFile(parsed, fileOwnResults, content)
|
||||
if (updated !== content) {
|
||||
await writeFile(filePath, updated)
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.keep) {
|
||||
process.stderr.write(`${fileResult.tmpDir}\n`)
|
||||
} else {
|
||||
await cleanupTmpDir(fileResult.tmpDir)
|
||||
}
|
||||
|
||||
return testResult
|
||||
}
|
||||
|
||||
const printErrorDot = (r: TestResult) => {
|
||||
if (r.error) {
|
||||
process.stdout.write(ansis.red("F"))
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.parallel) {
|
||||
const promises = files.map(async f => {
|
||||
const r = await runOne(f, nextPort++)
|
||||
printErrorDot(r)
|
||||
return r
|
||||
})
|
||||
results.push(...await Promise.all(promises))
|
||||
process.stdout.write("\n")
|
||||
} else {
|
||||
for (const filePath of files) {
|
||||
const r = await runOne(filePath, nextPort++)
|
||||
printErrorDot(r)
|
||||
results.push(r)
|
||||
}
|
||||
process.stdout.write("\n")
|
||||
}
|
||||
|
||||
// Print failures
|
||||
const failures = results.filter(r => !r.passed)
|
||||
if (failures.length > 0) {
|
||||
console.log()
|
||||
for (const f of failures) {
|
||||
console.log(formatFailure(f))
|
||||
console.log()
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - start
|
||||
const singleFile = files.length === 1 ? relative(cwd, files[0]) : undefined
|
||||
console.log(formatSummary(results, elapsed, singleFile))
|
||||
|
||||
process.exit(failures.length > 0 ? 1 : 0)
|
||||
})
|
||||
|
||||
program
|
||||
.command("version")
|
||||
.description("Print the version")
|
||||
.action(() => {
|
||||
console.log(pkg.version)
|
||||
})
|
||||
|
||||
program
|
||||
.command("example")
|
||||
.description("Print an example .shout file")
|
||||
.action(() => {
|
||||
console.log(`# Example .shout file
|
||||
$ echo hello
|
||||
hello
|
||||
|
||||
$ echo "one"; echo "two"; echo "three"
|
||||
one
|
||||
...
|
||||
three
|
||||
|
||||
$ cat nonexistent
|
||||
cat: nonexistent: ...
|
||||
[1]
|
||||
|
||||
$ true
|
||||
[0]`)
|
||||
})
|
||||
|
||||
program
|
||||
.command("upgrade")
|
||||
.description("Upgrade to the latest version")
|
||||
.action(async () => {
|
||||
const result = await Bun.spawn(["bun", "install", "-g", "@because/shout@latest"], {
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
}).exited
|
||||
process.exit(result)
|
||||
})
|
||||
|
||||
program.parse()
|
||||
|
|
@ -1,354 +0,0 @@
|
|||
There are no merge conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`) in the file you provided. The content is already clean. Here it is as-is:
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { parse, parseSetup } from "./parse.ts"
|
||||
|
||||
describe("parse", () => {
|
||||
test("simple command with output", () => {
|
||||
const result = parse("test.shout", "$ echo hello\nhello\n")
|
||||
expect(result.commands).toHaveLength(1)
|
||||
expect(result.commands[0]!.command).toBe("echo hello")
|
||||
expect(result.commands[0]!.expected).toEqual(["hello"])
|
||||
expect(result.commands[0]!.exitCode).toBeNull()
|
||||
})
|
||||
|
||||
test("multiple commands", () => {
|
||||
const content = "$ echo one\none\n\n$ echo two\ntwo\n"
|
||||
const result = parse("test.shout", content)
|
||||
expect(result.commands).toHaveLength(2)
|
||||
expect(result.commands[0]!.expected).toEqual(["one"])
|
||||
expect(result.commands[1]!.expected).toEqual(["two"])
|
||||
})
|
||||
|
||||
test("command with no expected output", () => {
|
||||
const result = parse("test.shout", "$ export FOO=bar\n$ echo $FOO\nbar\n")
|
||||
expect(result.commands).toHaveLength(2)
|
||||
expect(result.commands[0]!.expected).toEqual([])
|
||||
expect(result.commands[1]!.expected).toEqual(["bar"])
|
||||
})
|
||||
|
||||
test("strips trailing comment from command", () => {
|
||||
const result = parse("test.shout", '$ echo hello # a comment\nhello\n')
|
||||
expect(result.commands[0]!.command).toBe("echo hello")
|
||||
expect(result.commands[0]!.raw).toBe("$ echo hello # a comment")
|
||||
})
|
||||
|
||||
test("preserves # inside quotes", () => {
|
||||
const result = parse("test.shout", '$ echo "keep # this"\nkeep # this\n')
|
||||
expect(result.commands[0]!.command).toBe('echo "keep # this"')
|
||||
})
|
||||
|
||||
test("exit code [N]", () => {
|
||||
const result = parse("test.shout", "$ false\n[1]\n")
|
||||
expect(result.commands[0]!.exitCode).toBe(1)
|
||||
expect(result.commands[0]!.expected).toEqual([])
|
||||
})
|
||||
|
||||
test("exit code [*]", () => {
|
||||
const result = parse("test.shout", "$ false\noops\n[*]\n")
|
||||
expect(result.commands[0]!.exitCode).toBe("*")
|
||||
expect(result.commands[0]!.expected).toEqual(["oops"])
|
||||
})
|
||||
|
||||
test("exit code [42] with output", () => {
|
||||
const result = parse("test.shout", "$ sh -c 'echo err && exit 42'\nerr\n[42]\n")
|
||||
expect(result.commands[0]!.exitCode).toBe(42)
|
||||
expect(result.commands[0]!.expected).toEqual(["err"])
|
||||
})
|
||||
|
||||
test("blank lines in expected output", () => {
|
||||
const content = '$ echo -e "a\\n\\nb"\na\n\nb\n'
|
||||
const result = parse("test.shout", content)
|
||||
expect(result.commands[0]!.expected).toEqual(["a", "", "b"])
|
||||
})
|
||||
|
||||
test("trailing newline ignored", () => {
|
||||
const a = parse("test.shout", "$ echo hi\nhi\n")
|
||||
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("escaped dollar sign in expected output", () => {
|
||||
const content = "$ echo '$ hello'\n\\$ hello\n"
|
||||
const result = parse("test.shout", content)
|
||||
expect(result.commands).toHaveLength(1)
|
||||
expect(result.commands[0]!.expected).toEqual(["$ hello"])
|
||||
})
|
||||
|
||||
test("multiple escaped dollar signs", () => {
|
||||
const content = "$ printf '$ a\\n$ b\\n'\n\\$ a\n\\$ b\n"
|
||||
const result = parse("test.shout", content)
|
||||
expect(result.commands).toHaveLength(1)
|
||||
expect(result.commands[0]!.expected).toEqual(["$ a", "$ b"])
|
||||
})
|
||||
|
||||
test("escaped dollar before first command is ignored", () => {
|
||||
const content = "\\$ not a command\n$ echo hi\nhi\n"
|
||||
const result = parse("test.shout", content)
|
||||
// \$ before any command — no current command to attach to, so skipped
|
||||
expect(result.commands).toHaveLength(1)
|
||||
expect(result.commands[0]!.expected).toEqual(["hi"])
|
||||
})
|
||||
|
||||
test("$# comment line is skipped", () => {
|
||||
const result = parse("test.shout", "$# start the server\n$ echo hi\nhi\n")
|
||||
expect(result.commands).toHaveLength(1)
|
||||
expect(result.commands[0]!.command).toBe("echo hi")
|
||||
})
|
||||
|
||||
test("$# comment between commands", () => {
|
||||
const result = parse("test.shout", "$ echo one\none\n$# now do two\n$ echo two\ntwo\n")
|
||||
expect(result.commands).toHaveLength(2)
|
||||
expect(result.commands[0]!.expected).toEqual(["one"])
|
||||
expect(result.commands[1]!.expected).toEqual(["two"])
|
||||
})
|
||||
|
||||
test("$# comment with space after hash", () => {
|
||||
const result = parse("test.shout", "$ # server setup\n$ echo hi\nhi\n")
|
||||
expect(result.commands).toHaveLength(1)
|
||||
expect(result.commands[0]!.command).toBe("echo hi")
|
||||
})
|
||||
|
||||
test("$# comment as last line", () => {
|
||||
const result = parse("test.shout", "$ echo hi\nhi\n$# done\n")
|
||||
expect(result.commands).toHaveLength(1)
|
||||
expect(result.commands[0]!.expected).toEqual(["hi"])
|
||||
})
|
||||
|
||||
test("output after $# comment is ignored", () => {
|
||||
const result = parse("test.shout", "$ echo hi\nhi\n$# comment\nstray line\n$ echo bye\nbye\n")
|
||||
expect(result.commands).toHaveLength(2)
|
||||
expect(result.commands[0]!.expected).toEqual(["hi"])
|
||||
expect(result.commands[1]!.expected).toEqual(["bye"])
|
||||
})
|
||||
|
||||
test("no directives returns empty array", () => {
|
||||
const result = parse("test.shout", "$ echo hi\nhi\n")
|
||||
expect(result.directives).toEqual([])
|
||||
})
|
||||
|
||||
test("@teardown in .shout file", () => {
|
||||
const result = parse("test.shout", "@teardown rm -f /tmp/test.db\n$ echo hi\nhi\n")
|
||||
expect(result.teardownCommands).toHaveLength(1)
|
||||
expect(result.teardownCommands[0]!.command).toBe("rm -f /tmp/test.db")
|
||||
expect(result.commands).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("@teardown with @setup in .shout file", () => {
|
||||
const content = "@setup setup.shout\n@teardown rm -f /tmp/test.db\n@env PORT=3000\n$ echo hi\nhi\n"
|
||||
const result = parse("test.shout", content)
|
||||
expect(result.directives).toHaveLength(2)
|
||||
expect(result.directives[0]!.type).toBe("setup")
|
||||
expect(result.teardownCommands).toHaveLength(1)
|
||||
expect(result.teardownCommands[0]!.command).toBe("rm -f /tmp/test.db")
|
||||
})
|
||||
|
||||
test("unknown directive throws", () => {
|
||||
expect(() => parse("test.shout", "@evn PORT=3000\n$ echo hi\n")).toThrow(
|
||||
"test.shout:1: unknown directive: @evn PORT=3000",
|
||||
)
|
||||
})
|
||||
|
||||
test("@def simple macro", () => {
|
||||
const result = parse("test.shout", "@def greet echo hello\n$ greet\nhello\n")
|
||||
expect(result.directives).toEqual([
|
||||
{ type: "def", name: "greet", body: "echo hello", line: 1 },
|
||||
])
|
||||
expect(result.commands).toHaveLength(1)
|
||||
expect(result.commands[0]!.command).toBe("greet")
|
||||
})
|
||||
|
||||
test("@def with backslash continuation", () => {
|
||||
const content = "@def multi echo one; \\\n echo two\n$ multi\none\ntwo\n"
|
||||
const result = parse("test.shout", content)
|
||||
expect(result.directives).toEqual([
|
||||
{ type: "def", name: "multi", body: "echo one;\necho two", line: 1 },
|
||||
])
|
||||
expect(result.commands).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("@def with body starting on continuation line", () => {
|
||||
const content = "@def serve \\\n python3 -m http.server\n$ serve\n"
|
||||
const result = parse("test.shout", content)
|
||||
expect(result.directives).toEqual([
|
||||
{ type: "def", name: "serve", body: "python3 -m http.server", line: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
test("@def multiple macros", () => {
|
||||
const content = "@def foo echo foo\n@def bar echo bar\n$ foo\nfoo\n"
|
||||
const result = parse("test.shout", content)
|
||||
expect(result.directives).toHaveLength(2)
|
||||
expect(result.directives[0]).toEqual({ type: "def", name: "foo", body: "echo foo", line: 1 })
|
||||
expect(result.directives[1]).toEqual({ type: "def", name: "bar", body: "echo bar", line: 2 })
|
||||
})
|
||||
|
||||
test("@def without body throws", () => {
|
||||
expect(() => parse("test.shout", "@def greet\n$ echo hi\n")).toThrow(
|
||||
"test.shout:1: @def requires a name and body",
|
||||
)
|
||||
})
|
||||
|
||||
test("@def with whitespace-only body throws", () => {
|
||||
expect(() => parse("test.shout", "@def greet \n$ echo hi\n")).toThrow(
|
||||
"test.shout:1: @def requires a name and body",
|
||||
)
|
||||
})
|
||||
|
||||
test("@def continuation consuming $ line throws", () => {
|
||||
expect(() => parse("test.shout", "@def foo echo a \\\n$ echo real\n")).toThrow(
|
||||
"test.shout:2: @def continuation consumed a command or directive line",
|
||||
)
|
||||
})
|
||||
|
||||
test("@def continuation consuming @ directive throws", () => {
|
||||
expect(() => parse("test.shout", "@def foo echo a \\\n@env PORT=3000\n")).toThrow(
|
||||
"test.shout:2: @def continuation consumed a command or directive line",
|
||||
)
|
||||
})
|
||||
|
||||
test("@def trailing backslash with no continuation throws", () => {
|
||||
expect(() => parse("test.shout", "@def foo echo a \\\n")).toThrow(
|
||||
"test.shout:1: @def ends with \\ but has no continuation line",
|
||||
)
|
||||
})
|
||||
|
||||
test("@def continuation consuming blank line throws", () => {
|
||||
expect(() => parse("test.shout", "@def foo echo a \\\n\n$ echo real\n")).toThrow(
|
||||
"test.shout:2: @def continuation consumed a command or directive line",
|
||||
)
|
||||
})
|
||||
|
||||
test("@def continuation consuming comment line throws", () => {
|
||||
expect(() => parse("test.shout", "@def foo echo a \\\n# comment\n$ echo real\n")).toThrow(
|
||||
"test.shout:2: @def continuation consumed a command or directive line",
|
||||
)
|
||||
})
|
||||
|
||||
test("@def after first command is expected output", () => {
|
||||
const result = parse("test.shout", "$ cat file\n@def foo bar\n")
|
||||
expect(result.directives).toEqual([])
|
||||
expect(result.commands[0]!.expected).toEqual(["@def foo bar"])
|
||||
})
|
||||
})
|
||||
|
||||
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("@teardown commands in setup file", () => {
|
||||
const result = parseSetup("setup.shout", "export FOO=bar\n@teardown rm -f /tmp/test.db\n")
|
||||
expect(result.commands).toHaveLength(1)
|
||||
expect(result.commands[0]!.command).toBe("export FOO=bar")
|
||||
expect(result.teardownCommands).toHaveLength(1)
|
||||
expect(result.teardownCommands[0]!.command).toBe("rm -f /tmp/test.db")
|
||||
})
|
||||
|
||||
test("@teardown strips trailing comment", () => {
|
||||
const result = parseSetup("setup.shout", "@teardown rm -f /tmp/test.db # cleanup\n")
|
||||
expect(result.teardownCommands[0]!.command).toBe("rm -f /tmp/test.db")
|
||||
})
|
||||
|
||||
test("@teardown with empty command throws", () => {
|
||||
expect(() => parseSetup("setup.shout", "@teardown \n")).toThrow(
|
||||
"setup.shout:1: @teardown requires a command",
|
||||
)
|
||||
})
|
||||
|
||||
test("multiple @teardown commands", () => {
|
||||
const result = parseSetup("setup.shout", "@teardown rm -f a.db\n@teardown rm -f b.db\n")
|
||||
expect(result.teardownCommands).toHaveLength(2)
|
||||
expect(result.teardownCommands[0]!.command).toBe("rm -f a.db")
|
||||
expect(result.teardownCommands[1]!.command).toBe("rm -f b.db")
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
test("@def in setup file", () => {
|
||||
const result = parseSetup("setup.shout", "@def greet echo hello\nexport FOO=bar\n")
|
||||
expect(result.directives).toEqual([
|
||||
{ type: "def", name: "greet", body: "echo hello", line: 1 },
|
||||
])
|
||||
expect(result.commands).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("@def with backslash continuation in setup file", () => {
|
||||
const result = parseSetup("setup.shout", "@def multi echo one; \\\n echo two\n")
|
||||
expect(result.directives).toEqual([
|
||||
{ type: "def", name: "multi", body: "echo one;\necho two", line: 1 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
The TypeScript content you pasted doesn't contain any merge conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`). The file appears to be clean and complete.
|
||||
|
||||
I found merge conflicts in other files in the repo, but none in a `parse.ts` file. Could you clarify which file has the conflict, or re-paste the content with the conflict markers visible? It's possible they were stripped during message formatting.
|
||||
|
|
@ -9,8 +9,8 @@ fn shout() -> Command {
|
|||
#[test]
|
||||
fn test_suite_passes() {
|
||||
let status = shout()
|
||||
.args(["test", "test/"])
|
||||
.args(["test", "tests/"])
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success(), "shout test test/ exited with {status}");
|
||||
assert!(status.success(), "shout test tests/ exited with {status}");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user