Compare commits
No commits in common. "00b844cafcbd14c3bab34baaa250943234d5eeb0" and "327444a79acf7a94438d11290ada4c6a7a36a527" have entirely different histories.
00b844cafc
...
327444a79a
|
|
@ -23,7 +23,6 @@ Transcript-based shell integration test runner. Bun + TypeScript.
|
||||||
- `src/match.ts` — wildcard-aware output matching and diff generation
|
- `src/match.ts` — wildcard-aware output matching and diff generation
|
||||||
- `src/format.ts` — evaluates pass/fail, formats failures and summary
|
- `src/format.ts` — evaluates pass/fail, formats failures and summary
|
||||||
- `src/update.ts` — rewrites `.shout` files with actual output (`--update` mode)
|
- `src/update.ts` — rewrites `.shout` files with actual output (`--update` mode)
|
||||||
- `src/utils.ts` — shared utilities (`trimTrailingEmpty`, `escapeRegex`)
|
|
||||||
- `src/duration.ts` — parses duration strings (`10s`, `500ms`, `1m`)
|
- `src/duration.ts` — parses duration strings (`10s`, `500ms`, `1m`)
|
||||||
- `src/cli/index.ts` — CLI entry point via `commander`
|
- `src/cli/index.ts` — CLI entry point via `commander`
|
||||||
- `src/index.ts` — barrel exports
|
- `src/index.ts` — barrel exports
|
||||||
|
|
|
||||||
2
bun.lock
2
bun.lock
|
|
@ -12,6 +12,8 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/diff": "^8.0.0",
|
"@types/diff": "^8.0.0",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
11
package.json
11
package.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@because/shout",
|
"name": "@because/shout",
|
||||||
"version": "0.0.16",
|
"version": "0.0.15",
|
||||||
"description": "shell output tester",
|
"description": "shell output tester",
|
||||||
"module": "src/index.ts",
|
"module": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
@ -15,12 +15,19 @@
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"check": "bunx tsc --noEmit",
|
"check": "bunx tsc --noEmit",
|
||||||
|
"build": "./scripts/build.sh",
|
||||||
|
"cli:build": "bun run scripts/build.ts",
|
||||||
|
"cli:build:all": "bun run scripts/build.ts --all",
|
||||||
|
"cli:install": "bun cli:build && sudo cp dist/shout /usr/local/bin",
|
||||||
"cli:link": "ln -sf $(pwd)/src/cli/index.ts ~/.bun/bin/shout",
|
"cli:link": "ln -sf $(pwd)/src/cli/index.ts ~/.bun/bin/shout",
|
||||||
|
"cli:uninstall": "sudo rm /usr/local/bin",
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/diff": "^8.0.0",
|
"@types/diff": "^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,7 @@ program
|
||||||
sourceDir: resolve(dirname(filePath)),
|
sourceDir: resolve(dirname(filePath)),
|
||||||
projectDir: cwd,
|
projectDir: cwd,
|
||||||
timeout: timeoutMs,
|
timeout: timeoutMs,
|
||||||
|
verbose: opts.verbose ?? false,
|
||||||
onCommand: opts.verbose
|
onCommand: opts.verbose
|
||||||
? (cmd) => process.stderr.write(ansis.dim(` $ ${cmd.command}\n`))
|
? (cmd) => process.stderr.write(ansis.dim(` $ ${cmd.command}\n`))
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
@ -220,12 +221,11 @@ program
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.parallel) {
|
if (opts.parallel) {
|
||||||
const promises = files.map(async f => {
|
const all = await Promise.all(files.map(f => runOne(f, nextPort++)))
|
||||||
const r = await runOne(f, nextPort++)
|
for (const r of all) {
|
||||||
printDots(r)
|
printDots(r)
|
||||||
return r
|
results.push(r)
|
||||||
})
|
}
|
||||||
results.push(...await Promise.all(promises))
|
|
||||||
process.stdout.write("\n")
|
process.stdout.write("\n")
|
||||||
} else {
|
} else {
|
||||||
for (const filePath of files) {
|
for (const filePath of files) {
|
||||||
|
|
|
||||||
|
|
@ -72,21 +72,20 @@ export function formatFailure(test: TestResult): string {
|
||||||
lines.push(` ${ansis.dim("$")} ${failure.result.command.command}`)
|
lines.push(` ${ansis.dim("$")} ${failure.result.command.command}`)
|
||||||
|
|
||||||
if (failure.diffLines.length > 0) {
|
if (failure.diffLines.length > 0) {
|
||||||
const expectedLines: string[] = []
|
lines.push(ansis.red(" expected:"))
|
||||||
const actualLines: string[] = []
|
|
||||||
for (const dl of failure.diffLines) {
|
for (const dl of failure.diffLines) {
|
||||||
const text = dl.kind === "context" ? ansis.dim(dl.text) : dl.text
|
|
||||||
if (dl.kind === "expected" || dl.kind === "equal" || dl.kind === "context") {
|
if (dl.kind === "expected" || dl.kind === "equal" || dl.kind === "context") {
|
||||||
const prefix = dl.kind === "expected" ? ansis.red(" > ") : " "
|
const prefix = dl.kind === "expected" ? ansis.red(" > ") : " "
|
||||||
expectedLines.push(`${prefix}${text}`)
|
lines.push(`${prefix}${dl.kind === "context" ? ansis.dim(dl.text) : dl.text}`)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
lines.push(ansis.green(" actual:"))
|
||||||
|
for (const dl of failure.diffLines) {
|
||||||
if (dl.kind === "actual" || dl.kind === "equal" || dl.kind === "context") {
|
if (dl.kind === "actual" || dl.kind === "equal" || dl.kind === "context") {
|
||||||
const prefix = dl.kind === "actual" ? ansis.green(" > ") : " "
|
const prefix = dl.kind === "actual" ? ansis.green(" > ") : " "
|
||||||
actualLines.push(`${prefix}${text}`)
|
lines.push(`${prefix}${dl.kind === "context" ? ansis.dim(dl.text) : dl.text}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lines.push(ansis.red(" expected:"), ...expectedLines)
|
|
||||||
lines.push(ansis.green(" actual:"), ...actualLines)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (failure.exitCodeMismatch) {
|
if (failure.exitCodeMismatch) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import { escapeRegex } from "./utils.ts"
|
|
||||||
|
|
||||||
export function matchLine(pattern: string, actual: string): boolean {
|
export function matchLine(pattern: string, actual: string): boolean {
|
||||||
if (!pattern.includes("...")) return pattern === actual
|
if (!pattern.includes("...")) return pattern === actual
|
||||||
|
|
||||||
|
|
@ -10,6 +8,10 @@ export function matchLine(pattern: string, actual: string): boolean {
|
||||||
return regex.test(actual)
|
return regex.test(actual)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeRegex(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||||
|
}
|
||||||
|
|
||||||
export function matchOutput(
|
export function matchOutput(
|
||||||
expected: string[],
|
expected: string[],
|
||||||
actual: string[],
|
actual: string[],
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import { trimTrailingEmpty } from "./utils.ts"
|
|
||||||
|
|
||||||
export type Command = {
|
export type Command = {
|
||||||
line: number
|
line: number
|
||||||
raw: string
|
raw: string
|
||||||
|
|
@ -56,6 +54,12 @@ function parseExitCode(lines: string[]): {
|
||||||
return { lines, exitCode: null }
|
return { lines, exitCode: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trimTrailingEmpty(lines: string[]): string[] {
|
||||||
|
let end = lines.length
|
||||||
|
while (end > 0 && lines[end - 1] === "") end--
|
||||||
|
return lines.slice(0, end)
|
||||||
|
}
|
||||||
|
|
||||||
function parseEnvDirective(path: string, line: string, lineNum: number): { key: string; value: string } {
|
function parseEnvDirective(path: string, line: string, lineNum: number): { key: string; value: string } {
|
||||||
const rest = line.slice(5).trim()
|
const rest = line.slice(5).trim()
|
||||||
const eq = rest.indexOf("=")
|
const eq = rest.indexOf("=")
|
||||||
|
|
|
||||||
16
src/run.ts
16
src/run.ts
|
|
@ -3,7 +3,6 @@ import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
|
|
||||||
import type { Command, ShoutFile } from "./parse.ts"
|
import type { Command, ShoutFile } from "./parse.ts"
|
||||||
import { trimTrailingEmpty } from "./utils.ts"
|
|
||||||
|
|
||||||
export type CommandResult = {
|
export type CommandResult = {
|
||||||
command: Command
|
command: Command
|
||||||
|
|
@ -25,6 +24,7 @@ type RunOptions = {
|
||||||
sourceDir?: string
|
sourceDir?: string
|
||||||
projectDir?: string
|
projectDir?: string
|
||||||
timeout: number
|
timeout: number
|
||||||
|
verbose: boolean
|
||||||
onCommand?: (cmd: Command) => void
|
onCommand?: (cmd: Command) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,6 +92,7 @@ function buildScript(commands: Command[], sentinel: string, verbose: boolean): s
|
||||||
|
|
||||||
function parseSentinelOutput(
|
function parseSentinelOutput(
|
||||||
raw: string,
|
raw: string,
|
||||||
|
sentinel: string,
|
||||||
commandCount: number,
|
commandCount: number,
|
||||||
): { outputs: string[][]; exitCodes: number[] } {
|
): { outputs: string[][]; exitCodes: number[] } {
|
||||||
const outputs: string[][] = []
|
const outputs: string[][] = []
|
||||||
|
|
@ -99,7 +100,7 @@ function parseSentinelOutput(
|
||||||
|
|
||||||
// Split by sentinel lines
|
// Split by sentinel lines
|
||||||
const sentinelRegex = new RegExp(
|
const sentinelRegex = new RegExp(
|
||||||
`${SENTINEL_PREFIX}(\\d+)_(\\d+)__`,
|
`${escapeRegex(sentinel)}(\\d+)_(\\d+)__`,
|
||||||
)
|
)
|
||||||
|
|
||||||
let remaining = raw
|
let remaining = raw
|
||||||
|
|
@ -151,6 +152,16 @@ function stripAnsi(line: string): string {
|
||||||
return line.replace(ANSI_REGEX, "")
|
return line.replace(ANSI_REGEX, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trimTrailingEmpty(lines: string[]): string[] {
|
||||||
|
let end = lines.length
|
||||||
|
while (end > 0 && lines[end - 1] === "") end--
|
||||||
|
return lines.slice(0, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||||
|
}
|
||||||
|
|
||||||
function streamVerboseMarkers(
|
function streamVerboseMarkers(
|
||||||
stderr: ReadableStream<Uint8Array>,
|
stderr: ReadableStream<Uint8Array>,
|
||||||
commands: Command[],
|
commands: Command[],
|
||||||
|
|
@ -243,6 +254,7 @@ export async function runFile(
|
||||||
|
|
||||||
const { outputs, exitCodes } = parseSentinelOutput(
|
const { outputs, exitCodes } = parseSentinelOutput(
|
||||||
stdout,
|
stdout,
|
||||||
|
sentinel,
|
||||||
file.commands.length,
|
file.commands.length,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import type { CommandResult } from "./run.ts"
|
import type { CommandResult } from "./run.ts"
|
||||||
import type { ShoutFile } from "./parse.ts"
|
import type { ShoutFile } from "./parse.ts"
|
||||||
import { matchOutput } from "./match.ts"
|
import { matchOutput, matchLine } from "./match.ts"
|
||||||
import { isCommentLine } from "./parse.ts"
|
import { isCommentLine } from "./parse.ts"
|
||||||
import { trimTrailingEmpty } from "./utils.ts"
|
|
||||||
|
|
||||||
export function rewriteFile(
|
export function rewriteFile(
|
||||||
file: ShoutFile,
|
file: ShoutFile,
|
||||||
|
|
@ -84,3 +83,9 @@ export function rewriteFile(
|
||||||
function escapeDollar(line: string): string {
|
function escapeDollar(line: string): string {
|
||||||
return line.startsWith("$ ") ? "\\" + line : line
|
return line.startsWith("$ ") ? "\\" + line : line
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trimTrailingEmpty(lines: string[]): string[] {
|
||||||
|
let end = lines.length
|
||||||
|
while (end > 0 && lines[end - 1] === "") end--
|
||||||
|
return lines.slice(0, end)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
export function trimTrailingEmpty(lines: string[]): string[] {
|
|
||||||
let end = lines.length
|
|
||||||
while (end > 0 && lines[end - 1] === "") end--
|
|
||||||
return lines.slice(0, end)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function escapeRegex(s: string): string {
|
|
||||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
||||||
}
|
|
||||||
|
|
@ -27,6 +27,17 @@
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noPropertyAccessFromIndexSignature": false,
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
"baseUrl": "."
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"$*": [
|
||||||
|
"./src/server/*"
|
||||||
|
],
|
||||||
|
"@*": [
|
||||||
|
"./src/shared/*"
|
||||||
|
],
|
||||||
|
"%*": [
|
||||||
|
"./src/lib/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user