-
E2E black-box testing is the primary strategy. Tests exercise the
loopxbinary and programmatic API exactly as users would — by spawning processes, creating fixture scripts, and asserting observable behavior (exit codes, stdout, stderr, file system state). Internal implementation details are not tested directly. -
Contract-driven. Every test traces to a specific SPEC.md requirement. The test suite serves as an executable specification.
-
Runtime coverage. Tests run against both Node.js (>= 20.6) and Bun (>= 1.0). Where a test exercises runtime-specific behavior (e.g., module resolution), it is tagged accordingly.
-
Verification before implementation. Since the implementation doesn't exist yet, the test suite includes a verification strategy (section 3) to ensure tests are correctly constructed before they can pass.
-
Fuzz testing for parsers. The structured output parser and
.envfile parser are exercised with property-based tests to catch edge cases. -
Explicit internal test seams. The implementation must expose certain pure functions as package-private imports for unit and fuzz testing. This is a deliberate design decision — without these seams, high-volume fuzz testing is limited to E2E (50–100 inputs) instead of direct function calls (1000+ inputs). See section 1.4 for details.
| Priority | Category | Rationale |
|---|---|---|
| P0 | Loop state machine, structured output parsing, script execution | Core functionality — if these break, nothing works |
| P1 | Environment variables, CLI options, subcommands | Essential user-facing features |
| P2 | Install command, CLI delegation, signal handling | Important but less frequently exercised |
| P3 | Edge cases, fuzz tests | Defense in depth |
This suite is the implementation-driving test suite — it defines the behavior that must pass before a feature is considered complete. All SPEC.md requirements are covered by automated tests in this suite, including:
-
Spec 3.1 (Global Install): Covered by T-INST-GLOBAL-01, which exercises the full
npm pack→ install into isolated global prefix → run against fixture project workflow. This runs in CI on every build. -
Spec 7.3 (Signal Handling — between iterations): Covered by T-SIG-07, which sends a signal between iterations by coordinating via marker files. Tagged
@flaky-retry(3)due to inherent timing sensitivity. The active-child signal cases (T-SIG-01–06) are fully covered without retry. -
Spec 9.1 (Async Generator Cancellation): Multiple cancellation scenarios are tested: "break after yield" (T-API-06), "return during pending next" (T-API-09a), "abort signal during active child" (T-API-10a), "pre-aborted signal" (T-API-10b), and "abort between iterations" (T-API-10c).
The implementation must expose the following pure functions as package-private imports for unit and fuzz testing. These are not part of the public API and are not documented in SPEC.md — they exist solely to enable high-volume testing.
Required exports (via a subpath like loopx/internal or a src/internal.ts barrel):
| Function | Signature | Purpose |
|---|---|---|
parseOutput |
(stdout: string) => Output |
Parses raw stdout into structured Output per Spec 2.3 rules |
parseEnvFile |
(content: string) => { vars: Record<string, string>, warnings: string[] } |
Parses .env file content per Spec 8.1 rules. Returns parsed variables and any warning messages for invalid lines |
classifySource |
(source: string) => { type: "git" | "tarball" | "single-file", url: string } |
Classifies an install source per Spec 10.1 rules |
Design constraints:
- These functions must be pure — no I/O, no process spawning, no side effects. They take a string and return a value.
- The
warningsfield inparseEnvFilereturns the warning messages that would be printed to stderr during normal operation. This allows unit tests to assert on warning behavior without capturing stderr from a child process. - The exact module path and export mechanism is an implementation detail, but the test suite must be able to
import { parseOutput } from "loopx/internal"(or equivalent). The implementation may use TypeScriptpathsaliases, apackage.jsonexportssubpath, or a direct relative import from the test files. - These exports are not part of the public semver contract. They may change shape between minor versions.
These seams are a hard implementation requirement. Unit tests (section 6.1, 6.2) and high-volume fuzz tests (section 5.1, 5.2) depend on them. The implementation is not considered complete until these exports are available and importable by the test suite.
| Tool | Purpose |
|---|---|
| Vitest | Test runner and assertion library. Native ESM, TypeScript, fast. |
| fast-check | Property-based / fuzz testing for parsers. |
execa (or node:child_process) |
Spawning loopx CLI processes with fine-grained control over stdio, env, signals. |
| get-port | Acquiring free ports for local test servers. |
| http (Node built-in) | Local HTTP server for install tests (single-file + tarball). |
| Local bare git repos | Testing git clone install source (no network dependency). |
tests/
harness/
smoke.test.ts Phase 0 harness validation
e2e/
cli-basics.test.ts CLI invocation, help, version
subcommands.test.ts output, env, install subcommands
discovery.test.ts Script discovery & validation
execution.test.ts Script execution (bash, JS/TS, directory)
output-parsing.test.ts Structured output parsing
loop-state.test.ts Loop state machine & control flow
env-vars.test.ts Environment variable management
module-resolution.test.ts import from "loopx", output(), input()
programmatic-api.test.ts run(), runPromise()
install.test.ts loopx install from various sources
signals.test.ts Signal handling (SIGINT, SIGTERM)
delegation.test.ts Global-to-local CLI delegation
fuzz/
output-parsing.fuzz.test.ts
env-parsing.fuzz.test.ts
unit/
parse-output.test.ts Output parsing logic (supplementary)
parse-env.test.ts Env file parsing logic (supplementary)
source-detection.test.ts Install source classification
types.test.ts Compile-time type surface verification
helpers/
cli.ts CLI spawning utilities (runCLI, runCLIWithSignal)
api-driver.ts Programmatic API driver (runAPIDriver)
fixtures.ts Temp dir, script, and project creation
servers.ts Local HTTP & git servers, git URL rewriting
env.ts Env file creation, global config, isolated home
runtime.ts Runtime detection & matrix helpers
delegation.ts Delegation fixture setup (withDelegationSetup)
The helper library provides reusable utilities for all tests. Each helper is designed to be self-cleaning (via Vitest afterEach hooks or explicit cleanup functions).
Creates an isolated temporary directory with an optional .loopx/ subdirectory. Returns an object with:
dir: absolute path to the temp directoryloopxDir: absolute path to.loopx/within itcleanup(): removes the temp directory
All tests use this to avoid cross-contamination.
Creates a file script in the project's .loopx/ directory. Returns the full path. Example:
createScript(project, "myscript", ".ts", `
import { output } from "loopx";
output({ result: "hello" });
`);Creates a directory script in .loopx/ with a package.json containing the given main field, plus any additional files. Returns the directory path.
Shorthand for creating a .sh script with #!/bin/bash header and executable permission.
Spawns the loopx binary as a child process. Options include:
cwd: working directory (defaults to temp project)env: additional environment variablesruntime:"node"|"bun"— controls how the binary is invokedtimeout: max execution time (default 30s)input: string to pipe to stdin
Returns:
interface CLIResult {
exitCode: number;
stdout: string;
stderr: string;
signal: string | null;
}For Node.js, the CLI is spawned as node /path/to/loopx/bin.js [args].
For Bun, it is spawned as bun /path/to/loopx/bin.js [args].
Important: The CLI never prints result to its own stdout (Spec 7.1). To observe parsed Output objects, use runAPIDriver() or the programmatic API. runCLI is for asserting exit codes, stderr output, filesystem side effects, and control flow.
runAPIDriver(runtime, code, options?): Promise<{ stdout: string, stderr: string, exitCode: number }>
Spawns a tiny driver script under the specified runtime (Node.js or Bun) that imports from the loopx package, runs the provided code string, and prints JSON results to stdout. This is the correct way to test programmatic API behavior under Bun — importing loopx directly inside a Node-hosted Vitest process does not exercise Bun's runtime.
Import resolution: The driver must import from the built loopx package as a real consumer would. The helper creates a temporary consumer directory with a package.json and a symlinked (or file-protocol-linked) node_modules/loopx pointing to the build output. This ensures that import { run } from "loopx" exercises the actual package exports, not test-internal paths.
Example:
const result = await runAPIDriver("bun", `
import { runPromise } from "loopx";
const outputs = await runPromise("myscript", { cwd: "${project.dir}" });
console.log(JSON.stringify(outputs));
`, { cwd: project.dir });
const outputs = JSON.parse(result.stdout);Like runCLI, but also returns a sendSignal(signal) function and a waitForStderr(pattern) function so the test can send SIGINT/SIGTERM at a controlled point during execution.
Writes a .env format file with the given key-value pairs. Produces well-formed KEY=VALUE\n lines. Suitable for tests that only need valid, simple env files.
Writes raw text to a file at path with no transformation. This is the low-level helper for env parser tests that need to control exact file content — duplicate keys, malformed lines, unmatched quotes, comments, blank lines, missing trailing newlines, etc. createEnvFile cannot represent these cases.
Sets XDG_CONFIG_HOME to a temp directory, writes a global env file with the given vars, runs fn, then cleans up. This isolates global env tests from the user's real config.
Sets HOME to a temp directory and optionally unsets XDG_CONFIG_HOME, then runs fn, then restores. This safely tests the ~/.config default fallback path without touching the real home directory. Used for T-ENV-04 and related tests.
Provisions realistic delegation test fixtures: creates actual launcher files and symlinks in node_modules/.bin/loopx within a temp project. Returns paths and cleanup. This helper is used for T-DEL-* and LOOPX_BIN tests instead of runCLI, because runCLI's node /path/to/bin.js invocation does not exercise delegation or realpath resolution.
interface DelegationFixture {
projectDir: string;
globalBinPath: string; // path to "global" loopx binary
localBinPath: string; // path to "local" loopx in node_modules/.bin/
runGlobal(args: string[]): Promise<CLIResult>; // spawns the global binary
cleanup(): void;
}Starts a local HTTP server serving the specified routes. Used for install tests (single-file downloads, tarball downloads).
Creates local bare git repositories and serves them over a local protocol. Used for loopx install git tests. Implementation: create bare repos with git init --bare, then clone/commit/push fixture content, and serve via git daemon or direct file:// URLs.
Sets up an isolated git configuration (via GIT_CONFIG_GLOBAL and isolated HOME) with url.<base>.insteadOf rules so that known-host URLs (e.g., https://github.com/org/repo.git) are transparently rewritten to local file:// bare repos. This allows T-INST-01 through T-INST-04 to test known-host source detection without network access.
await withGitURLRewrite({
"https://github.com/myorg/my-script.git": "file:///tmp/bare-repos/my-script.git"
}, async () => {
const result = await runCLI(["install", "myorg/my-script"], { cwd: project.dir });
// Verifies org/repo shorthand expands to github URL, which is rewritten to local repo
});Test parameterization helper. Runs a test block once for each available runtime (Node.js, Bun). Skips a runtime if it's not installed. Example:
forEachRuntime((runtime) => {
it("runs a bash script", async () => {
const result = await runCLI(["-n", "1", "myscript"], { cwd: project.dir, runtime });
expect(result.exitCode).toBe(0);
});
});A catalog of reusable fixture scripts used across tests. Each is a function that returns the script content.
| Fixture | Type | Behavior |
|---|---|---|
emit-result(value) |
bash | printf '{"result":"%s"}' '<value>' — uses printf (not echo) to avoid trailing newline ambiguity |
emit-goto(target) |
bash | printf '{"goto":"%s"}' '<target>' |
emit-stop() |
bash | printf '{"stop":true}' |
emit-result-goto(value, target) |
bash | printf '{"result":"%s","goto":"%s"}' '<value>' '<target>' |
emit-raw(text) |
bash | printf '%s' '<text>' — exact bytes, no trailing newline unless explicitly included |
emit-raw-ln(text) |
bash | printf '%s\n' '<text>' — with trailing newline, for tests that need to verify newline handling |
exit-code(n) |
bash | exit <n> |
cat-stdin() |
bash | Reads stdin, echoes it as result |
write-stderr(msg) |
bash | echo '<msg>' >&2 then produces output |
sleep-then-exit(seconds) |
bash | Sleeps for <seconds>, then exits 0. General-purpose long-running script. For signal tests, prefer the dedicated signal-* fixtures which follow the ready-protocol. |
write-env-to-file(varname, markerPath) |
bash | printf '%s' "$VARNAME" to a marker file. Uses printf '%s' (not echo) to avoid trailing newline and backslash interpretation. Observation via filesystem, not CLI stdout. |
observe-env(varname, markerPath) |
ts | Writes JSON { "present": true, "value": "..." } or { "present": false } to a marker file using fs.writeFileSync. Distinguishes unset from empty string. Use instead of write-env-to-file when the test must differentiate between a variable being absent vs set to "". |
write-cwd-to-file(markerPath) |
bash | printf '%s' "$PWD" to a marker file. Uses printf '%s' (not echo) for exact-byte safety. |
write-value-to-file(value, markerPath) |
bash | printf '%s' '<value>' to a marker file. Uses printf '%s' (not echo) for exact-byte safety — avoids trailing newlines, backslash interpretation, and issues with values starting with -. General-purpose observation helper. |
stdout-writer(payloadFile) |
ts | Reads payloadFile from disk and writes its contents to stdout via process.stdout.write(). Used for fuzz and exact-byte output tests. |
ts-output(fields) |
ts | Uses import { output } from "loopx" to emit structured output |
ts-input-echo() |
ts | Reads input(), outputs it as result |
ts-import-check() |
ts | Imports from "loopx", outputs success marker |
signal-ready-then-sleep(markerPath) |
bash | Writes $$ (the script's PID) to a marker file using printf '%s', then writes "ready" to stderr, then sleeps indefinitely. The stderr marker allows the test harness to waitForStderr("ready") before sending a signal, ensuring the child is alive. |
signal-trap-exit(markerPath, delay) |
bash | Traps SIGTERM with a handler that sleeps for <delay> seconds then exits 0. On startup, writes $$ to a marker file using printf '%s' and writes "ready" to stderr. Used for grace-period tests — delay < 5s tests clean exit, delay > 5s tests SIGKILL escalation. |
signal-trap-ignore(markerPath) |
bash | Traps SIGTERM and ignores it (handler is a no-op). On startup, writes $$ to a marker file using printf '%s' and writes "ready" to stderr, then sleeps indefinitely. Used for SIGKILL-after-grace-period tests (T-SIG-05). |
spawn-grandchild(markerPath) |
bash | Spawns a background subprocess (e.g., sleep 3600 &), writes both $$ (script PID) and $! (grandchild PID) to a marker file (one per line) using printf '%s\n', writes "ready" to stderr, then waits. For process group signal tests (T-SIG-06). |
write-pid-to-file(markerPath) |
ts | Writes process.pid to a marker file using fs.writeFileSync, writes "ready" to stderr, then runs a long-running operation (e.g., setTimeout(() => {}, 999999)). Used for API cancellation tests (T-API-09a, T-API-10a) where a JS/TS script is needed. |
counter(file) |
bash | Appends "1" to a counter file each invocation, outputs count as result |
Fixture naming note: The emit-* fixtures replace the previous echo-* fixtures. printf is used instead of echo to provide exact byte control — echo appends a trailing newline which can mangle exact byte expectations in parser tests. For tests that specifically need to verify trailing-newline handling, use emit-raw-ln. The write-*-to-file fixtures observe values via the filesystem rather than CLI stdout, since the CLI never prints result to its own stdout (Spec 7.1). All bash write-*-to-file fixtures use printf '%s' (not echo) to write values, ensuring exact-byte safety for trailing spaces, backslashes, and values starting with -. The observe-env fixture is a TS-based alternative to write-env-to-file that writes structured JSON ({ "present": boolean, "value"?: string }) to a marker file using fs.writeFileSync for exact-byte safety. Use observe-env when a test must distinguish between a variable being unset vs set to an empty string — write-env-to-file (bash printf '%s' "$VAR") produces identical output for both cases.
Signal/cancellation fixtures: The signal-ready-then-sleep, signal-trap-exit, signal-trap-ignore, spawn-grandchild, and write-pid-to-file fixtures are purpose-built for signal and cancellation tests. They all follow a common protocol: (1) write PID(s) to a marker file on startup, (2) write "ready" to stderr, (3) block. The stderr marker allows waitForStderr("ready") to synchronize the test harness before sending signals, preventing races. The marker file PIDs allow post-test verification that processes were actually killed.
Bash JSON safety warning: The emit-result, emit-goto, and emit-result-goto fixtures use printf with %s substitution to produce JSON. This is only safe for simple string values that do not contain double quotes ("), backslashes (\), newlines, or other JSON-special characters — these would produce malformed JSON. For tests that require exact-byte control, JSON-special characters in values, or arbitrary binary content, use the stdout-writer TS fixture (which reads a pre-written payload from disk) or emit-raw/emit-raw-ln (which output exact bytes without JSON framing).
Tests are parameterized over runtimes where applicable:
| Category | Node.js | Bun |
|---|---|---|
| CLI basics, help, version | Yes | Yes |
| Subcommands (output, env) | Yes | Yes |
| Script discovery & validation | Yes | Yes |
| Bash script execution | Yes | Yes |
| JS/TS script execution | Yes | Yes |
Module resolution (import from "loopx") |
Yes (--import hook) | Yes (NODE_PATH) |
| Programmatic API | Yes | Yes |
| Install command | Yes | Yes |
| Signal handling | Yes | Yes |
| CLI delegation | Yes | Yes |
A test should be skipped (not failed) if its required runtime is not available in the environment.
A lightweight http.createServer instance serves fixture files for install tests:
- Single-file routes: Serve
.ts,.sh, etc. files at predictable paths. - Tarball routes: Serve
.tar.gzarchives created on-the-fly from fixture directories. - Query string routes: Serve files at URLs with
?token=abcto test query stripping. - Error routes: Return 404, 500, etc. to test error handling.
The server starts in beforeAll and closes in afterAll for the install test suite.
For git install tests, use file:// protocol URLs pointing to local bare repos:
- Create a temp directory with
git init --bare. - Clone it to a working directory, add fixture files (package.json + main entry), commit, push.
- Tests use
file:///path/to/bare/repo.gitas the install source.
This avoids any network dependency and is fast. The bare repos are created in beforeAll and cleaned up in afterAll.
The central challenge: tests are written before the implementation exists. We need confidence that when a test passes, it genuinely validates the spec requirement — not that it passes vacuously.
Purpose: Verify the test infrastructure works correctly. These tests pass without any loopx implementation.
Tests (tests/harness/smoke.test.ts):
- H-01: Temp project creation and cleanup.
createTempProject()creates a directory that exists,cleanup()removes it. - H-02: Script fixture creation.
createScript()writes a file to.loopx/with correct content and permissions. - H-03: Directory script fixture creation.
createDirScript()creates the expected directory structure withpackage.json. - H-04: Bash script is executable. A created
.shfixture has the execute permission bit set. - H-05: Env file creation.
createEnvFile()writes a file that can be read back and contains the expected content. - H-06: Process spawning captures exit code. Spawn
node -e "process.exit(42)"and assert exit code is 42. - H-07: Process spawning captures stdout. Spawn
echo helloand assert stdout is"hello\n". - H-08: Process spawning captures stderr. Spawn
node -e "console.error('err')"and assert stderr contains"err". - H-09: Process spawning respects cwd. Spawn
pwdwith a specific cwd and assert the output matches. - H-10: Process spawning respects env. Spawn
echo $MY_VARwithMY_VAR=helloand assert output. - H-11: Signal delivery works. Spawn a sleeping process, send SIGTERM, assert it terminates.
- H-12: Local HTTP server starts and serves content. Start server, fetch a route, assert response body.
- H-13: Local git repo is cloneable. Create a bare repo with fixture content, clone it, verify files exist.
- H-14: Runtime detection.
forEachRuntimecorrectly detects available runtimes. - H-15: Global env isolation.
withGlobalEnvuses a temp directory and doesn't touch the real~/.config.
All Phase 0 tests must pass before any Phase 1 tests are run. If Phase 0 fails, the test infrastructure is broken and Phase 1 results are meaningless. Vitest's --bail flag can enforce this.
Before the real implementation exists, we create a minimal stub — a shell script that:
- Exits 0 for all invocations
- Produces no stdout
- Ignores all arguments
When the Phase 1 (spec) tests are run against this stub, nearly all should fail. Any spec test that passes against the stub is suspect — it may be testing nothing. This is a one-time validation step, not a permanent part of CI.
Procedure:
- Create the stub binary:
#!/bin/bash exit 0
- Point
runCLIat the stub. - Run the spec test suite.
- Maintain a small allowlist of test IDs that are expected to pass against the stub for legitimate reasons (e.g.,
-n 0exits 0 coincidentally). Review only passes outside this allowlist. - Inspect unexpected passes: they either have a weak assertion that should be strengthened, or represent a genuine test error.
- Revise flagged tests to include assertions that would fail against the stub (e.g., check stderr for expected messages, verify specific stdout content, check file system side effects).
Each test file uses Vitest's describe blocks with category labels:
describe("HARNESS: ...")— Phase 0 tests. Must pass without implementation.describe("SPEC: ...")— Spec requirement tests. Expected to fail until implemented.describe("FUZZ: ...")— Property-based tests. Expected to fail until implemented.
During implementation, as features are built, the corresponding SPEC tests should transition from failing to passing. A test that continues to fail after its feature is implemented indicates either a bug in the implementation or a bug in the test.
Each test is identified by a unique ID (T-<SECTION>-<NUMBER>), references a SPEC.md section, and specifies its runtime scope. Unless marked [Node] or [Bun], tests run on both runtimes.
Spec refs: 4.1, 4.2, 11
- T-CLI-01:
loopx versionprints the bare package version string followed by a newline, exits 0. Assert exact stdout is${version}\n— the spec requires a trailing newline, so assert against the untrimmed stdout, not a trimmed comparison. No additional text or labels. Does not require.loopx/to exist. (Spec 4.3, 5.5) - T-CLI-02:
loopx -hprints usage text containing "loopx" and "usage" (case-insensitive), exits 0. (Spec 4.2) - T-CLI-03:
loopx --helpproduces the same output as-h. (Spec 4.2) - T-CLI-04:
loopx -hwith.loopx/containing scripts lists discovered script names in output. (Spec 11) - T-CLI-05:
loopx -hwithout.loopx/directory still prints help (no error), script list section is absent or empty. (Spec 5.5, 11) - T-CLI-06:
loopx -hwith.loopx/containing name collisions prints help with warnings on stderr. (Spec 11) - T-CLI-07:
loopx -hwith.loopx/containing reserved names prints help with warnings on stderr. (Spec 11) - T-CLI-07d:
loopx -hwith.loopx/containing a script with an invalid name (e.g.,-startswithdash.sh) prints help with a non-fatal warning on stderr about the invalid name. The invalid script is still listed in the help output (section 5.4 says invalid-name scripts are listed with a warning in help mode). Help still exits 0. (Spec 5.4, 11) - T-CLI-07a:
loopx -hwith.loopx/containing scripts lists script names and includes type information for each. Assert that each discovered script name appears in the output and that the output contains the file type (e.g., "ts", "sh") somewhere in the help text. Do not assert proximity between the name and type text, and do not assert an exact rendering format — only that both the name and its type are present. (Spec 11) - T-CLI-07b:
loopx -n 5 -hprints help and exits 0 (help flag takes precedence over other flags). (Spec 4.2) - T-CLI-07c:
loopx myscript -hprints help and exits 0 (help flag takes precedence over script name). (Spec 4.2) - T-CLI-07e:
loopx -h versionprints help and exits 0 (help flag takes precedence over subcommand). Theversionsubcommand does not execute. (Spec 4.2) - T-CLI-07f:
loopx -h env set FOO barprints help and exits 0 (help flag takes precedence overenvsubcommand). (Spec 4.2) - T-CLI-07g:
loopx -h --invalid-flagprints help and exits 0 (help flag takes precedence over invalid flags). (Spec 4.2) - T-CLI-07j:
loopx -h -e nonexistent.envprints help and exits 0 (help flag takes precedence over-e). The nonexistent env file is not read or validated. (Spec 4.2) - T-CLI-07h:
loopx -hwith.loopx/containing a directory script with a badpackage.json(invalid JSON) prints help with a non-fatal warning on stderr about the invalid directory script. Help still exits 0. The invalid directory script is not listed in the script listing. (Spec 5.1, 11) - T-CLI-07i:
loopx -hwith.loopx/containing a directory script whosemainescapes the directory (e.g.,"main": "../escape.ts") prints help with a non-fatal warning on stderr. Help still exits 0. (Spec 5.1, 11)
- T-CLI-08:
loopx -n 1(no script name) with adefault.tsscript in.loopx/runs the default script. Assert: script's output is observed (e.g., use a counter file fixture to prove it ran). (Spec 4.1) - T-CLI-09:
loopx(no script name) with nodefaultscript in.loopx/exits with code 1. Stderr contains a message mentioning "default" and suggesting script creation. (Spec 4.1) - T-CLI-10:
loopxwith.loopx/directory missing entirely exits with code 1 and provides a helpful error message. (Spec 7.2)
- T-CLI-11:
loopx -n 1 myscriptwith.loopx/myscript.shruns the script. Assert via counter file. (Spec 4.1) - T-CLI-12:
loopx nonexistentwith.loopx/existing but no matching script exits with code 1. (Spec 4.1) - T-CLI-13:
loopx -n 1 default(explicitly naming the default script) runs the default script, same asloopx -n 1with no name. (Spec 4.1)
- T-CLI-14:
loopx -n 3 myscriptwith a counter fixture runs exactly 3 iterations. Assert counter file contains 3 marks. (Spec 4.2, 7.1) - T-CLI-15:
loopx -n 0 myscriptexits 0 without running the script. Assert counter file does not exist or is empty. (Spec 4.2, 7.1) - T-CLI-16:
loopx -n -1 myscriptexits with code 1 (usage error). (Spec 4.2) - T-CLI-17:
loopx -n 1.5 myscriptexits with code 1 (usage error). (Spec 4.2) - T-CLI-18:
loopx -n abc myscriptexits with code 1 (usage error). (Spec 4.2) - T-CLI-19:
loopx -n 0with a missing script still exits with code 1 (validation occurs before-n 0short-circuit). Stderr contains an error about the missing script. (Spec 4.2, 7.1) - T-CLI-19a:
loopx -n 0with.loopx/directory missing entirely → exits with code 1 (validation occurs before-n 0short-circuit). Stderr contains an error about the missing.loopx/directory. (Spec 4.2, 7.1, 7.2) - T-CLI-20:
loopx -n 1 myscriptruns exactly 1 iteration even if the script produces nostop. (Spec 4.2)
- T-CLI-20a:
loopx -n 3 -n 5 myscriptexits with code 1 (duplicate-nis a usage error). (Spec 4.2) - T-CLI-20b:
loopx -e .env1 -e .env2 myscriptexits with code 1 (duplicate-eis a usage error). (Spec 4.2)
- T-CLI-21:
loopx -e .env -n 1 myscriptwith a valid.envfile makes its variables available in the script. Usewrite-env-to-filefixture: the script writes the env var value to a marker file. Assert the marker file contains the expected value. (Spec 4.2) - T-CLI-22:
loopx -e nonexistent.env myscriptexits with code 1. Stderr mentions the missing file. (Spec 4.2) - T-CLI-22a:
loopx -n 0 -e nonexistent.env myscriptexits with code 1 (env file validation happens before-n 0short-circuit). (Spec 4.2, 7.1) - T-CLI-22b:
loopx -n 0with.loopx/containing a name collision → exits with code 1 (validation occurs before-n 0short-circuit). (Spec 4.2, 5.2, 7.1) - T-CLI-22c:
loopx -n 0with.loopx/containing a reserved name → exits with code 1. (Spec 4.2, 5.3, 7.1) - T-CLI-22d:
loopx -n 0with.loopx/containing an invalid script name (e.g.,-bad.sh) → exits with code 1. (Spec 4.2, 5.4, 7.1)
- T-CLI-23:
loopx -n 1 myscriptwheremyscriptoutputs{"result":"hello"}and writes a marker file — the CLI's own stdout is empty (result is not printed), AND the marker file exists (proving the script actually ran). Both assertions are required to prevent vacuous passes. (Spec 7.1) - T-CLI-27:
loopx script1 script2(two positional arguments) exits with code 1. Stderr contains an error about unexpected arguments. The CLI grammar isloopx [options] [script-name]— singular. Only zero or one positional argument is accepted. (Spec 4.1)
Spec refs: 4.3, 5.5
- T-SUB-01:
loopx output --result "hello"prints valid JSON to stdout, exits 0. Parse stdout as JSON and assertresult === "hello". Do not assert exact byte-for-byte text or field order. (Spec 4.3) - T-SUB-02:
loopx output --goto "next"prints valid JSON to stdout, exits 0. Parse stdout as JSON and assertgoto === "next". (Spec 4.3) - T-SUB-03:
loopx output --stopprints valid JSON to stdout, exits 0. Parse stdout as JSON and assertstop === true. (Spec 4.3) - T-SUB-04:
loopx output --result "x" --goto "y" --stopprints valid JSON to stdout. Parse as JSON and assert all three fields present with correct values. (Spec 4.3) - T-SUB-05:
loopx outputwith no flags exits with code 1 (error). (Spec 4.3) - T-SUB-06:
loopx output --result "x"works without.loopx/directory existing. (Spec 5.5) - T-SUB-06a:
loopx output --result 'value with "quotes" and \\backslashes'→ stdout is valid JSON with the value correctly escaped. Parse stdout as JSON and assertresultequals the literal stringvalue with "quotes" and \backslashes. (Spec 4.3) - T-SUB-06b:
loopx output --result $'line1\nline2'(value containing a literal newline byte, passed as an array element to the process) → stdout is valid JSON with the newline correctly escaped as\nin the JSON string. Parse stdout as JSON and assertresultequals"line1\nline2"(two-line string). (Spec 4.3)
-
T-SUB-07:
loopx env set FOO barthenloopx env listshowsFOO=bar. (Spec 4.3) -
T-SUB-08:
loopx env set _UNDER scoresucceeds (underscore-prefixed name valid). Follow withloopx env listand assert_UNDER=scoreis present in the output. (Spec 4.3) -
T-SUB-09:
loopx env set A1 valsucceeds (alphanumeric name). Follow withloopx env listand assertA1=valis present in the output. (Spec 4.3) -
T-SUB-10:
loopx env set 1INVALID valexits with code 1 (starts with digit — wait,[A-Za-z_]for first char means digits are NOT valid as first char). (Spec 4.3)Correction to my earlier analysis: The env set validation pattern is
[A-Za-z_][A-Za-z0-9_]*. First character must be letter or underscore. Digits are only allowed after the first character. Test T-SUB-10 verifies that1INVALIDis rejected. -
T-SUB-11:
loopx env set -DASH valexits with code 1 (invalid name). (Spec 4.3) -
T-SUB-12:
loopx env set FOO barthenloopx env set FOO bazthenloopx env listshowsFOO=baz(overwrite). (Spec 4.3) -
T-SUB-13:
loopx env set FOO barin a directory with no.loopx/→ exits 0 ANDloopx env listsubsequently showsFOO=bar. Assert both success and the actual side effect, not just exit code. Also assert stderr does not contain script-validation warnings. (Spec 5.5) -
T-SUB-14:
loopx env setcreates the config directory ($XDG_CONFIG_HOME/loopx/) if it doesn't exist. (Spec 8.1) -
T-SUB-14a:
loopx env set KEY "value with spaces"→loopx env listshowsKEY=value with spaces. Value round-trips correctly. (Spec 4.3) -
T-SUB-14b:
loopx env set KEY "value#hash"→ value preserved including#. (Spec 4.3) -
T-SUB-14c:
loopx env set KEY "val=ue"→ value with=round-trips correctly. (Spec 4.3) -
T-SUB-14d:
loopx env set KEY <value containing an actual newline byte>→ rejected (multiline values not supported). Exit code 1. The test helper passes the argument as an array element containing a literal newline (e.g.,"value\nwith newline"), not via shell evaluation. (Spec 4.3) -
T-SUB-14e:
loopx env set KEY 'val"ue'→loopx env listshowsKEY=val"ue. Embedded double quotes round-trip correctly. (Spec 4.3) -
T-SUB-14f:
loopx env set KEY "value "→loopx env listshowsKEY=value. Trailing spaces in the value are preserved. (Spec 4.3) -
T-SUB-14g:
loopx env set KEY <value containing an actual CR byte>→ rejected (carriage return, like newline, is not supported). Exit code 1. The test helper passes the argument as an array element containing a literal carriage return (e.g.,"value\rwith cr"), not via shell evaluation. (Spec 4.3)
These tests verify the actual bytes written to the env file, not just round-tripping. The spec (4.3) defines the serialization as KEY="<literal value>" followed by a newline. Read the raw file content after env set and assert the exact serialized line.
- T-SUB-14h:
loopx env set FOO bar→ read the global env file. Assert it contains the lineFOO="bar"\n(double-quoted, with a trailing newline). (Spec 4.3) - T-SUB-14i:
loopx env set FOO "value with spaces"→ file containsFOO="value with spaces"\n. (Spec 4.3) - T-SUB-14j:
loopx env set FOO 'val"ue'→ file containsFOO="val"ue"\n(no escaping — value is written literally within double quotes). (Spec 4.3) - T-SUB-14k:
loopx env set FOO ""(empty value) → file containsFOO=""\n. (Spec 4.3)
- T-SUB-15:
loopx env set FOO barthenloopx env remove FOOthenloopx env list—FOOis absent. (Spec 4.3) - T-SUB-16:
loopx env remove NONEXISTENTexits with code 0 (silent no-op). (Spec 4.3)
- T-SUB-17: With no variables set,
loopx env listproduces no stdout output, exits 0. (Spec 4.3) - T-SUB-18: With variables
ZEBRA=z,ALPHA=a,MIDDLE=mset,loopx env listoutputs them sorted:ALPHA=a,MIDDLE=m,ZEBRA=z. (Spec 4.3) - T-SUB-19:
loopx env listin a directory with no.loopx/→ exits 0 and produces no stdout output (since no vars are set). Assert both the exit code and that stdout is empty. Also assert stderr does not contain script-validation warnings. (Spec 5.5)
Spec refs: 5.1–5.5
- T-DISC-01:
.loopx/myscript.shis discoverable.loopx -n 1 myscriptruns it. Assert via marker file (usewrite-value-to-filefixture): the script writes a known value to a marker file, and the marker file exists with the expected content after execution. Exit code 0 alone is not sufficient. (Spec 5.1) - T-DISC-02:
.loopx/myscript.jsis discoverable.loopx -n 1 myscriptruns it. Assert via marker file: the script writes a known value to a marker file, confirming execution. (Spec 5.1) - T-DISC-03:
.loopx/myscript.jsxis discoverable.loopx -n 1 myscriptruns it. Assert via marker file: the script writes a known value to a marker file, confirming execution. (Spec 5.1) - T-DISC-04:
.loopx/myscript.tsis discoverable.loopx -n 1 myscriptruns it. Assert via marker file: the script writes a known value to a marker file, confirming execution. (Spec 5.1) - T-DISC-05:
.loopx/myscript.tsxis discoverable.loopx -n 1 myscriptruns it. Assert via marker file: the script writes a known value to a marker file, confirming execution. (Spec 5.1) - T-DISC-06:
.loopx/myscript.mjsis NOT discoverable.loopx -n 1 myscriptfails with "not found." (Spec 2.1, 5.1) - T-DISC-07:
.loopx/myscript.cjsis NOT discoverable. (Spec 2.1, 5.1) - T-DISC-08:
.loopx/myscript.txtis NOT discoverable. (Spec 5.1) - T-DISC-09:
.loopx/myscript(no extension) is NOT discoverable. (Spec 5.1) - T-DISC-10: Script name is base name without extension.
.loopx/my-script.ts→ name ismy-script. (Spec 2.1)
- T-DISC-11:
.loopx/mypipe/withpackage.json("main": "index.ts") andindex.ts→ discoverable asmypipe.loopx -n 1 mypiperuns it. Assert via marker file orrunPromise({ maxIterations: 1 }): the entry point executes and produces an observable output. (Spec 2.1, 5.1) - T-DISC-11a:
.loopx/mypipe/withpackage.json("main": "src/index.ts") andsrc/index.ts→ discoverable asmypipe. This verifies thatmaincan point to a subpath within the directory, not just a top-level file. (Spec 2.1, 5.1) - T-DISC-12:
.loopx/nopackage/directory with nopackage.json→ ignored.loopx -n 1 nopackagefails. (Spec 2.1, 5.1) - T-DISC-13:
.loopx/nomain/withpackage.jsonthat has nomainfield → ignored. (Spec 2.1, 5.1) - T-DISC-14:
.loopx/mypipe/with"main": "index.sh"→ discoverable (bash entry point).loopx -n 1 mypiperuns it. Assert via marker file: the entry point executes and writes a known value to a marker file. (Spec 5.1) - T-DISC-14a:
.loopx/mypipe/with"main": "index.js"→ discoverable (JS entry point).loopx -n 1 mypiperuns it. Assert via marker file: the entry point executes and writes a known value. (Spec 5.1) - T-DISC-14b:
.loopx/mypipe/with"main": "index.jsx"→ discoverable (JSX entry point).loopx -n 1 mypiperuns it. Assert via marker file: the entry point executes and writes a known value. (Spec 5.1) - T-DISC-14c:
.loopx/mypipe/with"main": "index.tsx"→ discoverable (TSX entry point).loopx -n 1 mypiperuns it. Assert via marker file: the entry point executes and writes a known value. (Spec 5.1) - T-DISC-15:
.loopx/mypipe/with"main": "index.py"→ warning on stderr, directory ignored. (Spec 5.1) - T-DISC-16:
.loopx/mypipe/with"main": "../escape.ts"→ warning on stderr, directory ignored. (Spec 5.1) - T-DISC-16a:
.loopx/mypipe/withpackage.jsoncontaining invalid JSON (e.g.,{invalid}) → warning on stderr, directory ignored.loopx -n 1 mypipefails. (Spec 5.1) - T-DISC-16b:
.loopx/mypipe/with unreadablepackage.json(e.g., no read permissions) → warning on stderr, directory ignored. This test is conditional onprocess.getuid() !== 0— root can read any file, so the test is skipped when running as root (e.g., in some CI containers). (Spec 5.1) - T-DISC-16c:
.loopx/mypipe/withpackage.jsonwheremainis not a string (e.g.,{"main": 42}) → warning on stderr, directory ignored. (Spec 5.1) - T-DISC-16d:
.loopx/mypipe/withpackage.jsonwheremainpoints to a file that does not exist (e.g.,{"main": "missing.ts"}) → warning on stderr, directory ignored. (Spec 5.1) - T-DISC-17: Script name is directory name.
.loopx/my-pipeline/→ name ismy-pipeline. (Spec 2.1)
- T-DISC-18:
.loopx/example.shand.loopx/example.tsboth exist → loopx refuses to start with error listing the conflicting entries. Exit code 1. (Spec 5.2) - T-DISC-19:
.loopx/example.tsand.loopx/example/(valid directory script) → collision error. (Spec 5.2) - T-DISC-20: Three-way collision (
.loopx/example.sh,.loopx/example.js,.loopx/example/) → error lists all conflicting entries. (Spec 5.2) - T-DISC-21: Non-conflicting scripts with different names → no error.
.loopx/alpha.shand.loopx/beta.tscoexist. Runloopx -n 1 alphaand assert via marker file thatalphaexecuted successfully. (Spec 5.2)
- T-DISC-22:
.loopx/output.sh→ loopx refuses to start with error mentioning "reserved." (Spec 5.3) - T-DISC-23:
.loopx/env.ts→ same error. (Spec 5.3) - T-DISC-24:
.loopx/install.js→ same error. (Spec 5.3) - T-DISC-25:
.loopx/version.sh→ same error. (Spec 5.3) - T-DISC-26: Reserved name as directory script (
.loopx/output/with valid package.json) → same error. (Spec 5.3)
- T-DISC-27:
.loopx/-startswithdash.sh→ error. (Spec 5.4) - T-DISC-28:
.loopx/my-script.sh(hyphen in middle) → valid. Runloopx -n 1 my-scriptand assert via marker file that the script executed. (Spec 5.4) - T-DISC-29:
.loopx/_underscore.sh→ valid. Runloopx -n 1 _underscoreand assert via marker file that the script executed. (Spec 5.4) - T-DISC-30:
.loopx/ABC123.sh→ valid. Runloopx -n 1 ABC123and assert via marker file that the script executed. (Spec 5.4) - T-DISC-30a:
.loopx/1start.sh→ valid (digits are allowed as the first character of a script name per[a-zA-Z0-9_]).loopx -n 1 1startruns it. Assert via marker file: the script writes a known value to a marker file, confirming execution. (Spec 5.4) - T-DISC-30b:
.loopx/42.sh→ valid (all-digit script name).loopx -n 1 42runs it. Assert via marker file: the script writes a known value to a marker file, confirming execution. (Spec 5.4) - T-DISC-31:
.loopx/has space.sh→ error (space not in allowed pattern). (Spec 5.4) - T-DISC-32:
.loopx/has.dot.sh— the base name ishas.dot(everything before.sh). This contains a.which is not in[a-zA-Z0-9_-]. → error. (Spec 5.4)
- T-DISC-33: Symlink to a
.tsfile inside.loopx/→ followed, script discoverable. (Spec 5.1) - T-DISC-34: Symlinked directory in
.loopx/with valid package.json → followed, discoverable. (Spec 5.1) - T-DISC-35: Directory script whose
mainis a symlink to a file within the directory → valid. (Spec 5.1) - T-DISC-36: Directory script whose
mainis a symlink that resolves outside the directory → warning, ignored. (Spec 5.1)
- T-DISC-37: During a loop (
-n 3), create a new script in.loopx/between iteration 1 and 2 (using a script that creates a file). Then have iteration 2gotothe new script name → error (not in cached discovery). (Spec 5.1) - T-DISC-38: During a loop, modify the content of an already-discovered script between iterations. Assert the new content takes effect on the next iteration (since the file is re-read from disk). (Spec 5.1)
- T-DISC-38a: During a multi-iteration loop, an already-discovered script is removed (deleted from disk) between iterations. On the next iteration that would execute this script (either via goto or as the starting target), loopx fails at spawn time because the cached entry path no longer exists. Assert loopx exits with code 1. Use a two-script setup: script A writes a marker file and outputs
goto:"B"on the first iteration; between iterations, a side-effect from A removes script B's file. When loopx tries to spawn B, it fails. (Spec 5.1, 7.2) - T-DISC-38b: During a multi-iteration loop, an already-discovered script is renamed (moved to a different filename) between iterations. The cached entry path becomes stale. On the next iteration that would execute the original script, loopx fails at spawn time. Assert loopx exits with code 1. (Spec 5.1, 7.2)
- T-DISC-50:
.loopx/contains a valid scriptgood.shand an invalid directory scriptbad/(with malformedpackage.json).loopx -n 1 goodruns the valid script successfully (assert via marker file) AND stderr contains a warning about the invalid directory script. This verifies that discovery warnings are emitted in run mode, not just help mode. (Spec 5.1)
- T-DISC-39:
loopx versionworks when.loopx/doesn't exist. (Spec 5.5) - T-DISC-40:
loopx env set X Ywhen.loopx/doesn't exist → exits 0, ANDloopx env listsubsequently showsX=Y. Assert stderr does not contain script-validation warnings. (Spec 5.5) - T-DISC-41:
loopx output --result "x"when.loopx/doesn't exist → exits 0, AND stdout contains valid JSON withresult: "x". Assert stderr does not contain script-validation warnings. (Spec 5.5) - T-DISC-42:
loopx(run mode) when.loopx/doesn't exist → error, exit 1. (Spec 5.5) - T-DISC-43:
loopx versionwhen.loopx/exists and contains name collisions or reserved names → exits 0 with a version string on stdout. Assert stderr does not contain script-validation warnings (validation not performed). (Spec 5.5) - T-DISC-44:
loopx env set X Ywhen.loopx/exists and contains collisions → exits 0, ANDloopx env listsubsequently showsX=Y. Assert stderr does not contain script-validation warnings. (Spec 5.5) - T-DISC-45:
loopx output --result "x"when.loopx/exists and contains reserved names → exits 0, AND stdout contains valid JSON withresult: "x". Assert stderr does not contain script-validation warnings. (Spec 5.5) - T-DISC-46:
loopx install <source>when.loopx/exists and contains collisions → the install succeeds (exits 0, installed script present in.loopx/). Assert stderr does not contain script-validation warnings about existing scripts. (Spec 5.5) - T-DISC-46a:
loopx env remove Xwhen.loopx/doesn't exist → exits 0 (silent no-op, since there is no variable to remove). Assert stderr does not contain script-validation warnings. (Spec 5.5, 4.3) - T-DISC-46b:
loopx env remove Xwhen.loopx/exists and contains name collisions → exits 0 (theenv removesubcommand does not trigger script validation). Assert stderr does not contain script-validation warnings. (Spec 5.5, 4.3)
- T-DISC-47: A parent directory has
.loopx/with scripts, but the current working directory does not have.loopx/.loopx myscriptin the child directory fails — parent.loopx/is not discovered. (Spec 5.1) - T-DISC-49:
.loopx/subdir/nested.ts(a.tsfile nested inside a non-script subdirectory within.loopx/) is NOT discovered.loopx -n 1 nestedfails. Discovery is top-level only — subdirectories without a validpackage.jsonand their contents are ignored. (Spec 5.1)
- T-DISC-48: During a multi-iteration loop, change a discovered directory script's
package.jsonmainfield between iterations. Assert the change is not picked up (the original entry point continues to run). (Spec 5.1)
Spec refs: 6.1–6.4
- T-EXEC-01: File script (
.loopx/check-cwd.sh) writes$PWDto a marker file. Assert the marker file content equals the directory where loopx was invoked (the project root). (Spec 6.1) - T-EXEC-02: Directory script (
.loopx/mypipe/) writes$PWDto a marker file from its entry point. Assert marker content equals the absolute path of.loopx/mypipe/. (Spec 6.1) - T-EXEC-03: File script writes
$LOOPX_PROJECT_ROOTto a marker file. Assert marker content equals the invocation directory. (Spec 6.1) - T-EXEC-04: Directory script writes
$LOOPX_PROJECT_ROOTto a marker file. Assert marker content equals the invocation directory (not the script's own directory). (Spec 6.1)
- T-EXEC-05: A
.shscript runs successfully and its stdout is captured as structured output. Observe viarunPromise({ maxIterations: 1 }): the yielded Output contains the expectedresult. (Spec 6.2) - T-EXEC-06: A
.shscript's stderr appears on the CLI's stderr (pass-through). Assert by writing a known string to stderr and checking CLI stderr. (Spec 6.2) - T-EXEC-07: A
.shscript that lacks#!/bin/bashstill runs (loopx invokes via/bin/bashexplicitly, not via shebang). Assert actual execution via marker file: the script writes a known value to a marker file, confirming it ran despite having no shebang. (Spec 6.2)
- T-EXEC-08:
.tsscript runs and produces structured output. Observe viarunPromise({ maxIterations: 1 }): the yielded Output has the expectedresult. (Spec 6.3) - T-EXEC-09:
.jsscript runs and produces structured output. Observe viarunPromise({ maxIterations: 1 }). (Spec 6.3) - T-EXEC-10:
.tsxscript runs and produces structured output. Observe viarunPromise({ maxIterations: 1 }). The fixture must use actual TSX syntax to verify that the runtime handles JSX transformation, not just extension acceptance. Use a self-contained JSX pragma that does not depend on React: e.g.,/** @jsxImportSource ./jsx-shim */where the shim'sjsx()function returns a plain string, or useReact.createElement = (tag: string) => tag;and writeconst el = <div/>;. The script outputs{ result: String(el) }. This proves the TSX-to-JS compilation actually ran. (Spec 6.3) - T-EXEC-11:
.jsxscript runs and produces structured output. Observe viarunPromise({ maxIterations: 1 }). Same approach as T-EXEC-10 — the fixture must use actual JSX syntax (e.g.,const el = <div/>;with a custom pragma or shim) to verify JSX transformation works, not just that the extension is accepted. (Spec 6.3) - T-EXEC-12: JS/TS script stderr passes through to CLI stderr. (Spec 6.3)
- T-EXEC-13: JS/TS script can use TypeScript type annotations (verifies tsx handles TS syntax under Node.js).
[Node](Spec 6.3) - T-EXEC-13b: JS/TS script can use TypeScript type annotations under Bun (verifies Bun's native TS support).
[Bun](Spec 6.3) - T-EXEC-13a: A
.jsscript that usesrequire()(CJS) fails with an error. CJS is not supported. (Spec 6.3) - T-EXEC-14:
[Bun]When running under Bun, JS/TS scripts are executed via Bun's native runtime, nottsx. Create a.tsfixture script that writesJSON.stringify({ bunVersion: process.versions.bun })as its result. Observe viarunPromise({ maxIterations: 1 }): the yielded Output'sresultparsed as JSON has a truthybunVersionstring, confirming the child process ran under Bun (not Node.js/tsx). The spec requires: "When running under Bun, loopx uses Bun's native TypeScript/JSX support instead of tsx." (Spec 6.3)
- T-EXEC-15: Directory script with
"main": "index.ts"→index.tsis executed. Observe viarunPromise({ maxIterations: 1 }): the yielded Output has the expectedresult. (Spec 6.4) - T-EXEC-16: Directory script with
"main": "run.sh"→run.shis executed via bash. Observe viarunPromise({ maxIterations: 1 }). (Spec 6.4) - T-EXEC-17: Directory script can import from its own
node_modules/. Setup: create a directory script with a local dependency (a simple.jsfile innode_modules/). Script writes a marker file confirming the import succeeded. (Spec 2.1) - T-EXEC-18: Directory script CWD is its own directory. Script writes
process.cwd()to a marker file. Assert marker content matches the script's directory. (Spec 6.1) - T-EXEC-18a: Directory script that imports a package not present in its
node_modules/(e.g.,import "nonexistent-pkg") — loopx does not auto-install dependencies. The script fails with a module resolution error from the active runtime, and loopx exits with code 1. (Spec 2.1)
Spec refs: 2.3
These tests use bash fixture scripts that echo specific strings to stdout. Parsing correctness is asserted by examining the actual yielded Output object via the programmatic API (run(), runPromise(), or runAPIDriver()), not by inferring from loop behavior alone. This is critical because distinct parsing outcomes (raw fallback, structured output with invalid fields, empty stdout) can produce identical control flow but different Output objects.
- T-PARSE-01: Script outputs
{"result":"hello"}. Assert viarunPromise({ maxIterations: 1 }): yielded Output is{ result: "hello" }— nogotoorstopproperties present. (Spec 2.3) - T-PARSE-02: Script outputs
{"goto":"next"}→ loopx transitions to scriptnext. (Spec 2.3) - T-PARSE-03: Script outputs
{"stop":true}→ loop halts, exit code 0. (Spec 2.3) - T-PARSE-04: Script outputs
{"result":"x","goto":"next","stop":true}→ stop takes priority, loop halts. (Spec 2.3) - T-PARSE-05: Script outputs
{"result":"x","extra":"ignored"}. Assert viarunPromise({ maxIterations: 1 }): yielded Output hasresult: "x"and noextraproperty. (Spec 2.3)
- T-PARSE-06: Script outputs
{"unknown":"field"}(valid JSON object, no known fields). Assert viarunPromise({ maxIterations: 1 }): yielded Output is{ result: '{"unknown":"field"}' }— entire stdout becomes the raw result string, not an empty object. (Spec 2.3) - T-PARSE-07: Script outputs
[1,2,3](JSON array). Assert viarunPromise({ maxIterations: 1 }): yielded Output hasresult: "[1,2,3]". (Spec 2.3) - T-PARSE-08: Script outputs
"hello"(JSON string). Assert viarunPromise({ maxIterations: 1 }): yielded Output hasresult: '"hello"'(the raw stdout, including the quotes). (Spec 2.3) - T-PARSE-09: Script outputs
42(JSON number). Assert viarunPromise({ maxIterations: 1 }): yielded Output hasresult: "42"(raw stdout string, not a parsed number). (Spec 2.3) - T-PARSE-10: Script outputs
true(JSON boolean). Assert viarunPromise({ maxIterations: 1 }): yielded Output hasresult: "true"(raw stdout string). (Spec 2.3) - T-PARSE-11: Script outputs
null(JSON null). Assert viarunPromise({ maxIterations: 1 }): yielded Output hasresult: "null"(raw stdout string). (Spec 2.3) - T-PARSE-12: Script outputs
not json at all. Assert viarunPromise({ maxIterations: 1 }): yielded Output hasresult: "not json at all". (Spec 2.3) - T-PARSE-12a: Raw fallback preserves exact stdout including trailing newline. Script outputs
hello\n(usingemit-raw-ln("hello")fixture —printf '%s\n' 'hello'). Assert viarunPromise({ maxIterations: 1 }): yielded Output hasresult: "hello\n"— the trailing newline is part of the raw result, not stripped. (Spec 2.3) - T-PARSE-13: Script produces empty stdout (no output). Assert via
runPromise({ maxIterations: 1 }): yielded Output is{ result: "" }— not an empty object{}. (Spec 2.3)
- T-PARSE-14:
{"result": 42}. Assert viarunPromise({ maxIterations: 1 }): yielded Output hasresult: "42"(coerced viaString()). (Spec 2.3) - T-PARSE-15:
{"result": true}. Assert viarunPromise({ maxIterations: 1 }): yielded Output hasresult: "true". (Spec 2.3) - T-PARSE-16:
{"result": {"nested": "obj"}}. Assert viarunPromise({ maxIterations: 1 }): yielded Output hasresult: "[object Object]". (Spec 2.3) - T-PARSE-17:
{"result": null}. Assert viarunPromise({ maxIterations: 1 }): yielded Output hasresult: "null". (Spec 2.3) - T-PARSE-18:
{"goto": 42}(goto is not a string). Assert viarunPromise({ maxIterations: 1 }): yielded Output is exactly{}— an empty object with noresult,goto, orstopproperties. The output is parsed as structured (it is a JSON object with a known field), but the invalid-typedgotois discarded. This is distinct from raw fallback, which would yield{ result: '{"goto":42}' }. (Spec 2.3) - T-PARSE-19:
{"goto": true}. Assert viarunPromise({ maxIterations: 1 }): yielded Output is exactly{}. (Spec 2.3) - T-PARSE-20:
{"goto": null}. Assert viarunPromise({ maxIterations: 1 }): yielded Output is exactly{}. (Spec 2.3) - T-PARSE-21:
{"stop": "true"}(string, not boolean). Assert viarunPromise({ maxIterations: 1 }): yielded Output is exactly{}. Loop continues (does not halt). (Spec 2.3) - T-PARSE-22:
{"stop": 1}. Assert viarunPromise({ maxIterations: 1 }): yielded Output is exactly{}. (Spec 2.3) - T-PARSE-23:
{"stop": false}. Assert viarunPromise({ maxIterations: 1 }): yielded Output is exactly{}. (Spec 2.3) - T-PARSE-24:
{"stop": "false"}. Assert viarunPromise({ maxIterations: 1 }): yielded Output is exactly{}. (Spec 2.3) - T-PARSE-20a:
{"goto":""}(empty string goto). An empty string IS a string (typeof "" === "string"), so the parser must preserve it — it is NOT treated as absent. Assert viarun(): the generator throws an error on the first iteration (because""is not a valid script name), confirming the empty string was forwarded to the goto validation logic rather than silently discarded. This is distinct from T-PARSE-18/19/20 which test non-string goto values that are discarded. (Spec 2.3)
- T-PARSE-28:
{"result":"x","goto":42}(valid result + invalid goto). Assert viarunPromise({ maxIterations: 1 }): yielded Output is exactly{ result: "x" }— the validresultis preserved, the invalid-typedgotois discarded. Nogotoorstopproperties present. (Spec 2.3) - T-PARSE-29:
{"result":"x","stop":"true"}(valid result + invalid stop). Assert viarunPromise({ maxIterations: 1 }): yielded Output is exactly{ result: "x" }— the validresultis preserved, the invalid-typedstopis discarded. Loop continues (does not halt). (Spec 2.3)
- T-PARSE-25: Script outputs JSON with trailing newline
{"result":"x"}\n. Assert viarunPromise({ maxIterations: 1 }): yielded Output hasresult: "x"(parsed correctly, not raw fallback). (Spec 2.3) - T-PARSE-26: Script outputs pretty-printed JSON (with newlines and indentation). Assert via
runPromise({ maxIterations: 1 }): yielded Output has the expected fields (parsed correctly). (Spec 2.3) - T-PARSE-27: Script outputs JSON with leading whitespace. Assert via
runPromise({ maxIterations: 1 }): yielded Output has the expected fields (parsed correctly). (Spec 2.3)
Spec refs: 2.2, 7.1, 7.2, 6.7, 6.8
- T-LOOP-01: Script produces no output → loop resets, starting target runs again. Use counter fixture with
-n 3and verify 3 runs. (Spec 2.2, 7.1) - T-LOOP-02: Script A →
goto:"B"→ B produces no output → starting target A runs again. Use counter fixtures for both A and B with-n 4. Assert A ran twice, B ran twice (A, B, A, B). (Spec 2.2) - T-LOOP-03: A →
goto:"B"→ B →goto:"C"→ C → no goto → A (back to start). With-n 4, assert execution order A, B, C, A. (Spec 2.2) - T-LOOP-04: Script outputs
{"stop":true}on first iteration → loop runs once, exits 0. (Spec 2.2) - T-LOOP-05: A runs 3 times (no goto/stop), then outputs
{"stop":true}on 4th. Assert exactly 4 iterations. Use a counter-based script. (Spec 2.2)
- T-LOOP-06:
-n 1→ exactly 1 iteration. (Spec 7.1) - T-LOOP-07:
-n 3with script that never stops → exactly 3 iterations. (Spec 7.1) - T-LOOP-08:
-n 3with A →goto:"B"→ B → no goto. Execution: A, B, A. That's 3 iterations. Verify with counter fixtures. (Spec 7.1) - T-LOOP-09:
-n 2with A →goto:"B". Execution: A (1), B (2). Verify B ran but A didn't run again. (Spec 7.1) - T-LOOP-10:
-n 0→ no iterations, script never runs. (Spec 7.1)
- T-LOOP-11: A outputs
{"result":"payload","goto":"B"}. B reads stdin (viacat-stdinfixture) and outputs the received value as its result. Observe viarunPromise({ maxIterations: 2 }): the second yielded Output hasresult: "payload". (Spec 6.7) - T-LOOP-12: A outputs
{"goto":"B"}(no result). B reads stdin and outputs it as result. Observe viarunPromise({ maxIterations: 2 }): the second yielded Output hasresult: ""(empty string). (Spec 2.3, 6.7) - T-LOOP-13: A outputs
{"result":"payload"}(no goto). Loop resets to A. A reads stdin and outputs it as result. Observe viarunPromise({ maxIterations: 2 }): the second yielded Output hasresult: ""(result not piped on reset). (Spec 6.7) - T-LOOP-14: First iteration (starting target) receives empty stdin. Script reads stdin and outputs it as result. Observe via
runPromise({ maxIterations: 1 }): yielded Output hasresult: "". (Spec 6.8) - T-LOOP-15: A →
goto:"B"with result → B →goto:"C"with result → C reads stdin and outputs it as result. Observe viarunPromise({ maxIterations: 3 }): the third yielded Output hasresultequal to B's result, not A's. (Spec 6.7)
- T-LOOP-16: Goto is a transition, not permanent. A →
goto:"B"→ B → no goto → A runs again (not B). (Spec 2.2) - T-LOOP-17: A →
goto:"A"(self-referencing goto). Verify it works: A runs, then A runs again (2 iterations with -n 2). (Spec 2.2) - T-LOOP-18: Goto target that doesn't exist → error, exit code 1. Stderr mentions the invalid target name. (Spec 7.2)
- T-LOOP-19: Goto to a script that was not discovered (e.g., a
.mjsfile) → error. (Spec 7.2) - T-LOOP-18a: Script outputs
{"goto":""}(empty string goto). An empty string is a valid string per section 2.3 parsing rules, so it is preserved as a goto value. However,""does not match any script name in.loopx/(script names must match[a-zA-Z0-9_][a-zA-Z0-9_-]*). Assert: loopx exits with code 1, stderr contains an error about the invalid goto target. The empty string goto must not be silently treated as absent (which would cause the loop to reset instead of erroring). (Spec 2.3, 7.1, 7.2)
- T-LOOP-20: Script exits with code 1 → loop stops immediately. loopx exits with code 1. (Spec 7.2)
- T-LOOP-21: Script exits with code 2 → same behavior (any non-zero is an error). (Spec 7.2)
- T-LOOP-22: Script fails on iteration 3 of 5 (
-n 5). Assert exactly 3 iterations ran (loop stopped at failure). (Spec 7.2) - T-LOOP-23: Script's stderr output on failure is visible on CLI stderr. (Spec 7.2)
- T-LOOP-24: Script's stdout on failure is NOT parsed as structured output. Use a script that prints
{"result":"should-not-appear","stop":true}to stdout then exits 1. Observe viarun(): the generator should throw on the failing iteration without yielding anyOutputfor that iteration. If the JSON were parsed, it would yield a result and halt cleanly — the throw with no yield proves it was not parsed. (Spec 7.2)
- T-LOOP-25:
-n 2with script producing{"result":"iter-N"}. Both iterations' outputs are observable via programmatic API. Verify programmatic API yields both. (Spec 7.1)
Spec refs: 8.1–8.3
All env tests use withGlobalEnv to isolate from the real user config.
- T-ENV-01: Variable set via
loopx env setis available in a script. Usewrite-env-to-filefixture: the script writes$VAR_NAMEto a marker file. Assert the marker file contains the expected value. (Spec 8.1, 8.3) - T-ENV-02: Variable removed via
loopx env removeis no longer available in scripts. Useobserve-envfixture: the script writes JSON to a marker file. Assert the marker file contains{ "present": false }(variable truly unset, not merely empty). (Spec 8.1) - T-ENV-03:
XDG_CONFIG_HOMEis respected. SetXDG_CONFIG_HOME=/tmp/custom, runloopx env set X Y, verify file exists at/tmp/custom/loopx/env. (Spec 8.1) - T-ENV-04: When
XDG_CONFIG_HOMEis unset, default is~/.config. UsewithIsolatedHome(notwithGlobalEnv) to safely verify the fallback path without touching the real home directory. (Spec 8.1) - T-ENV-05: Config directory created on first
env set. Start with no directory, runenv set, verify directory was created. (Spec 8.1) - T-ENV-05a: Unreadable global env file. Create the global env file, then
chmod 000it. Runloopx -n 1 myscript→ exits with code 1 and an error message about the unreadable file. This test is conditional onprocess.getuid() !== 0— root can read any file, so the test is skipped when running as root. (Spec 8.1) - T-ENV-05b: Unreadable global env file via programmatic API. Same setup as T-ENV-05a (
chmod 000).run("myscript")returns a generator; on the firstnext(), the generator throws an error about the unreadable file. Conditional onprocess.getuid() !== 0. (Spec 8.1, 9.3) - T-ENV-05c: Unreadable global env file with
loopx env list. Create the global env file with content, thenchmod 000. Runloopx env list→ exits with code 1 and an error message about the unreadable file. Conditional onprocess.getuid() !== 0. (Spec 8.1) - T-ENV-05d: Unreadable global env file with
loopx env set. Create the global env file with content, thenchmod 000. Runloopx env set KEY val→ exits with code 1 and an error message about the unreadable file. Theenv setcommand must read the existing file before writing (to preserve other entries), so an unreadable file is an error. Conditional onprocess.getuid() !== 0. (Spec 8.1) - T-ENV-05e: Unreadable global env file with
loopx env remove. Create the global env file with content, thenchmod 000. Runloopx env remove KEY→ exits with code 1 and an error message about the unreadable file. Theenv removecommand must read the existing file to remove a key, so an unreadable file is an error. Conditional onprocess.getuid() !== 0. (Spec 8.1)
All env file parsing tests below use writeEnvFileRaw to write exact file content, then a write-env-to-file fixture script to observe the parsed value via a marker file.
- T-ENV-06:
writeEnvFileRaw(path, "KEY=VALUE\n"). Script writes$KEYto marker file. Assert marker containsVALUE. (Spec 8.1) - T-ENV-07:
writeEnvFileRaw(path, "# comment\nKEY=val\n"). AssertKEY=valis loaded, comment line produces no variable. (Spec 8.1) - T-ENV-08:
writeEnvFileRaw(path, "\n\nKEY=val\n\n"). Blank lines ignored,KEY=valloaded. (Spec 8.1) - T-ENV-09:
writeEnvFileRaw(path, "X=first\nX=second\n"). Duplicate keys: last occurrence wins. Script seesX=second. (Spec 8.1) - T-ENV-10:
writeEnvFileRaw(path, 'KEY="hello world"\n'). Double-quoted value → value ishello world(quotes stripped). (Spec 8.1) - T-ENV-11:
writeEnvFileRaw(path, "KEY='hello world'\n"). Single-quoted value → value ishello world. (Spec 8.1) - T-ENV-12:
writeEnvFileRaw(path, 'KEY="hello\\nworld"\n'). No escape sequences → value is literalhello\nworld(backslash + n, not newline). (Spec 8.1) - T-ENV-13:
writeEnvFileRaw(path, "KEY=value#notcomment\n"). Inline#is part of value → value isvalue#notcomment. (Spec 8.1) - T-ENV-14:
writeEnvFileRaw(path, "KEY=value \n"). Trailing whitespace on value trimmed → value isvalue. (Spec 8.1) - T-ENV-15:
writeEnvFileRaw(path, "KEY = value\n"). No whitespace around=: the key isKEYwhich contains a space. Test that this does NOT setKEYtovalue. Assert a warning on stderr about the invalid key. (Spec 8.1) - T-ENV-15f:
writeEnvFileRaw(path, "KEY= value\n"). The value is everything after the first=to end of line, trimmed of trailing whitespace. Script seesKEYwith valuevalue(leading space preserved). (Spec 8.1) - T-ENV-15a:
writeEnvFileRaw(path, "KEY=\n"). Empty value. Useobserve-envfixture: assert marker contains{ "present": true, "value": "" }— the variable is present with an empty string value, not absent. (Spec 8.1) - T-ENV-15b:
writeEnvFileRaw(path, "KEY=a=b=c\n"). Multiple=. Script seesKEYwith valuea=b=c(split on first=). (Spec 8.1) - T-ENV-15c:
writeEnvFileRaw(path, "1BAD=val\n"). Invalid key (starts with digit). Line ignored with warning to stderr. Useobserve-envfixture with varname1BAD: assert marker contains{ "present": false }— confirming the variable is truly unset. (Spec 8.1) - T-ENV-15d:
writeEnvFileRaw(path, "justtext\n"). Malformed non-comment line without=. Line ignored with warning to stderr. (Spec 8.1) - T-ENV-15e:
writeEnvFileRaw(path, 'KEY="hello\n'). Unmatched quotes: opening double quote, no closing. "Wrapped" requires both opening and closing quotes of the same type — an unmatched quote is not wrapping, so the literal value"hello(including the quote character) is preserved. (Spec 8.1)
- T-ENV-16:
-e local.envloads variables into script environment. Script writes the env var to a marker file. Assert marker file contains the expected value. (Spec 8.2) - T-ENV-17:
-e nonexistent.env→ error, exit 1. (Spec 8.2) - T-ENV-17a:
-e unreadable.env→ error, exit 1. Create a local env file, thenchmod 000it. Runloopx -e unreadable.env -n 1 myscript→ exits with code 1 and an error message. Behavior is identical to an unreadable global env file (Spec 8.1). This test is conditional onprocess.getuid() !== 0. (Spec 8.2) - T-ENV-18: Global has
X=global, local hasX=local. Script writes$Xto a marker file → marker containslocal. (Spec 8.2) - T-ENV-19: Global has
A=1, local hasB=2. Script writes both to marker files →A=1andB=2both present. (Spec 8.2)
- T-ENV-20:
LOOPX_BINis always set, even if the user setsLOOPX_BIN=fakein global/local env. Script writes$LOOPX_BINto a marker file → marker contains the real binary path, not"fake". (Spec 8.3) - T-ENV-20a:
LOOPX_BINoverrides inherited system environment. Spawn loopx withLOOPX_BIN=fakein the process environment (not via env files). Script writes$LOOPX_BINto a marker file → marker contains the real binary path, not"fake". (Spec 8.3) - T-ENV-21:
LOOPX_PROJECT_ROOTalways set, overrides user-supplied value. Script writes$LOOPX_PROJECT_ROOTto a marker file → marker contains the real invocation directory. (Spec 8.3) - T-ENV-21a:
LOOPX_PROJECT_ROOToverrides inherited system environment. Spawn loopx withLOOPX_PROJECT_ROOT=/fake/pathin the process environment. Script writes$LOOPX_PROJECT_ROOTto a marker file → marker contains the real invocation directory, not"/fake/path". (Spec 8.3) - T-ENV-22: System env has
SYS_VAR=sys, global env hasSYS_VAR=global. Script writes$SYS_VARto a marker file → marker containsglobal. (Spec 8.3) - T-ENV-23: System env has
SYS_VAR=sys, no loopx override. Script writes$SYS_VARto a marker file → marker containssys. (Spec 8.3) - T-ENV-24: Full precedence chain. Set
VARat system, global, and local levels. Script writes$VARto a marker file. Assert local wins. Then remove from local → global wins. Then remove from global → system wins. (Spec 8.3) - T-ENV-24a:
LOOPX_DELEGATEDis not visible in script execution environments. Spawn loopx withLOOPX_DELEGATED=1in the process environment (simulating post-delegation state). A script uses theobserve-envfixture to write the presence/value ofLOOPX_DELEGATEDto a marker file. Assert the marker file contains{ "present": false }. The spec (section 8.3) exhaustively lists the loopx-injected variables asLOOPX_BINandLOOPX_PROJECT_ROOT—LOOPX_DELEGATEDis an internal delegation guard (section 3.2) and must not leak into script environments. (Spec 8.3, 3.2) - T-ENV-24b: Empty-string value in local env file overrides non-empty global/system value. System env has
MY_VAR=system, global env hasMY_VAR=global, local env file setsMY_VAR=(empty string). Script writes$MY_VARto a marker file viaobserve-env. Assert marker contains{ "present": true, "value": "" }— the local empty string wins, not the global or system value. This verifies that the env merge logic uses nullish coalescing (not logical OR), preserving empty strings as valid values per the precedence chain. (Spec 8.3)
- T-ENV-25: During a multi-iteration loop, modify the global env file between iterations (use a script that rewrites the env file as a side effect on iteration 1). On iteration 2, a different script writes
$VARto a marker file. Assert the marker contains the original value (env loaded once at start, not re-read). (Spec 8.1) - T-ENV-25a: During a multi-iteration loop with
-e local.env, modifylocal.envbetween iterations (use a script that rewrites the local env file as a side effect on iteration 1). On iteration 2, a different script writes$VARto a marker file viaobserve-env. Assert the marker contains the original value (local env file loaded once at start, not re-read per iteration). (Spec 8.2)
Spec refs: 3.3, 3.4, 6.5, 6.6
- T-MOD-01: A TS script with
import { output } from "loopx"runs successfully under Node.js. The script callsoutput({ result: "resolved" }). Observe viarunPromise({ maxIterations: 1 }): yielded Output hasresult: "resolved".[Node](Spec 3.3) - T-MOD-02: Same import works under Bun. The script calls
output({ result: "resolved" }). Observe viarunAPIDriver("bun", ...): yielded Output hasresult: "resolved".[Bun](Spec 3.3) - T-MOD-03: A JS script with
import { output } from "loopx"also works. The script callsoutput({ result: "resolved" }). Observe viarunPromise({ maxIterations: 1 }): yielded Output hasresult: "resolved". (Spec 3.3) - T-MOD-03a: A directory script that has its own
node_modules/loopx(a different version) resolvesimport from "loopx"to the local package, not the running CLI's package. Standard module resolution applies — the closestnode_moduleswins. The local shadow package exports anoutput()function that writes a distinctive marker to a marker file before writing JSON to stdout. The test asserts that the shadow's marker file exists (proving the local package was resolved). This verifies the spec's "standard module resolution applies" behavior (Spec 2.1, 3.3). (Spec 3.3, 2.1)
These tests observe output() behavior via the programmatic API (run() / runPromise()) or side-effect files — not by inspecting CLI stdout, because the CLI never prints result to its own stdout (Spec 7.1).
- T-MOD-04: Script uses
output({ result: "hello" }). Observe viarunPromise("myscript", { maxIterations: 1 }): yielded Output hasresult: "hello". (Spec 6.5) - T-MOD-05: Script uses
output({ result: "x", goto: "y" }). Observe viarun(): yielded Output has bothresult: "x"andgoto: "y", and loopx transitions to scripty. (Spec 6.5) - T-MOD-06: Script uses
output({ stop: true }). Observe viarunPromise(): loop completes after one iteration, yielded Output hasstop: true. (Spec 6.5) - T-MOD-07: Script uses
output({})(no known fields). Script crashes with non-zero exit code.runPromise()rejects. (Spec 6.5) - T-MOD-08: Script uses
output(null). Script crashes with non-zero exit code. (Spec 6.5) - T-MOD-09: Script uses
output(undefined). Script crashes with non-zero exit code. (Spec 6.5) - T-MOD-10: Script uses
output("string"). Observe viarunPromise("myscript", { maxIterations: 1 }): yielded Output hasresult: "string". (Spec 6.5) - T-MOD-11: Script uses
output(42). Observe viarunPromise("myscript", { maxIterations: 1 }): yielded Output hasresult: "42". (Spec 6.5) - T-MOD-12: Script uses
output(true). Observe viarunPromise("myscript", { maxIterations: 1 }): yielded Output hasresult: "true". (Spec 6.5) - T-MOD-13: Script uses
output({ result: "x", goto: undefined }). Observe viarunPromise("myscript", { maxIterations: 1 }): yielded Output hasresult: "x"and nogotoproperty. (Spec 6.5) - T-MOD-13a: Script uses
output([1, 2, 3])(array, no known fields). Script crashes with non-zero exit code. (Spec 6.5) - T-MOD-13c: Script uses
output({ foo: "bar" })(object with no known fields —foois notresult,goto, orstop). Script crashes with non-zero exit code. This is distinct from T-MOD-07 (output({})) — it verifies that an object must have at least one known field, not just any field. (Spec 6.5) - T-MOD-13b: Script uses
output({ result: undefined, goto: undefined, stop: undefined }). All known fields areundefined, which are omitted during JSON serialization — equivalent tooutput({}). Script crashes with non-zero exit code (no known fields with defined values). (Spec 6.5) - T-MOD-13d: Script uses
output({ stop: false }). Thestopfield has a defined value (false), which is a known field — sooutput()accepts the object (it has at least one known field with a defined value). The emitted JSON{"stop":false}is then parsed by the loop engine, wherestopmust be exactlytrue(boolean) to take effect. Observe viarunPromise({ maxIterations: 2 }): the first yielded Output is{}(thestop: falseis discarded during parsing), and the loop continues to a second iteration rather than halting. (Spec 6.5, 2.3) - T-MOD-13e: Script uses
output({ goto: 42 }). Thegotofield has a defined value (42), which is a known field — sooutput()accepts the object. The emitted JSON{"goto":42}is then parsed by the loop engine, wheregotomust be a string. Observe viarunPromise({ maxIterations: 2 }): the first yielded Output is{}(the non-stringgotois discarded during parsing), and the loop resets to the starting target rather than transitioning. (Spec 6.5, 2.3) - T-MOD-13f: Script uses
output({ result: null }). Theresultfield has a defined value (null), which is a known field — sooutput()accepts the object. The emitted JSON{"result":null}is then parsed by the loop engine, where non-stringresultis coerced viaString(). Observe viarunPromise({ maxIterations: 1 }): yielded Output hasresult: "null". (Spec 6.5, 2.3) - T-MOD-13g: Script uses
output({ goto: null }). Thegotofield has a defined value (null), which is a known field — sooutput()accepts the object. The emitted JSON{"goto":null}is then parsed by the loop engine, wheregotomust be a string. Observe viarunPromise({ maxIterations: 2 }): the first yielded Output is{}(the nullgotois discarded during parsing), and the loop resets to the starting target. (Spec 6.5, 2.3) - T-MOD-14: Code after
output()does not execute. Script:output({ result: "a" }); writeFileSync("/tmp/marker", "ran"). Assert marker file does not exist. (Spec 6.5) - T-MOD-14a: Large-payload flush: Script uses
output({ result: "x".repeat(1_000_000) })(1 MB result). Observe viarunPromise("myscript", { maxIterations: 1 }): yielded Output has the full 1 MB string without truncation. This exercises the flush-before-exit guarantee in section 6.5. (Spec 6.5)
- T-MOD-15:
input()returns empty string on first iteration (no prior input). Script callsinput()and outputs the result. Observe viarunPromise("myscript", { maxIterations: 1 }): yielded Output hasresult: "". (Spec 6.6) - T-MOD-16: A →
output({ result: "payload", goto: "B" })→ B callsinput()→ receives"payload". (Spec 6.6) - T-MOD-17:
input()called twice in the same script returns the same value (cached). (Spec 6.6) - T-MOD-18:
input()returns a Promise (test by awaiting it). (Spec 6.6)
- T-MOD-22:
[Node]Attemptingrequire("loopx")from a CommonJS consumer script fails with a module format error. Create a temporary consumer directory withpackage.json(no"type": "module") and a.jsfile containingconst loopx = require("loopx"). Run it under Node.js. Assert it exits non-zero with an error indicating the package is ESM-only (e.g., ERR_REQUIRE_ESM). This verifies the published package contract from Spec section 1. (Spec 1)
These tests verify LOOPX_BIN through loop behavior and side-effect files — not by inspecting CLI stdout. These tests use withDelegationSetup (or the real executable path), not runCLI, because runCLI's node /path/to/bin.js invocation does not exercise realpath resolution of LOOPX_BIN.
- T-MOD-19: Bash script uses
$LOOPX_BIN output --result "payload" --goto "reader"to produce structured output. A second scriptreaderreads stdin and writes the received value to a marker file. Assert the marker file contains"payload". (Spec 3.4) - T-MOD-20: Bash script writes
$LOOPX_BINto a marker file. Assert the marker file contains a valid path to an executable file (file exists and is executable). (Spec 3.4) - T-MOD-21: Bash script runs
$LOOPX_BIN versionand captures its stdout to a marker file. Assert the marker file content (trimmed) matches loopx's ownpackage.jsonversionfield. (Spec 3.4)
Spec refs: 9.1–9.5
Runtime-matrix methodology: All programmatic API tests that run under both Node.js and Bun use runAPIDriver() to spawn a driver process under the target runtime. This is the correct way to test API behavior under Bun — importing loopx directly inside a Node-hosted Vitest process does not exercise Bun's runtime. Node-only direct-import tests may exist as supplementary smoke tests, but they do not substitute for runAPIDriver()-based coverage in the runtime matrix.
- T-API-01:
run("myscript")returns an async generator. Callingnext()yields anOutputobject. (Spec 9.1) - T-API-02: Generator yields one
Outputper iteration. With-n 3, collect all yields → array of 3 outputs. (Spec 9.1) - T-API-03: Generator completes (returns
{ done: true }) when script outputsstop: true. (Spec 9.1) - T-API-04: Generator completes when
maxIterationsis reached. (Spec 9.1) - T-API-05: The output from the final iteration is yielded before the generator completes. (Spec 9.1)
- T-API-06: Breaking out of
for awaitloop after the first yield prevents further iterations from starting. Setup: script produces a result (nostop) on each iteration, withmaxIterations: 10. Break after receiving the first yield. Assert: no additional iterations execute (use a counter file — counter should be exactly 1). (Spec 9.1) - T-API-07:
run("myscript", { cwd: "/path/to/project" })resolves scripts relative to the given cwd. (Spec 9.5) - T-API-08:
run("myscript", { maxIterations: 0 })→ generator completes immediately with no yields. (Spec 9.5) - T-API-08a:
run("nonexistent", { maxIterations: 0 })→run()returns a generator. On the firstnext(), the generator throws because the script does not exist. Even withmaxIterations: 0, validation (discovery, script resolution, env file loading) runs before the zero-iteration short-circuit. This mirrors CLI-n 0behavior (T-CLI-19). (Spec 9.1, 9.5, 7.1) - T-API-09:
run()with no script name runs thedefaultscript. (Spec 9.1) - T-API-09b:
run()cwd snapshot timing. Create two temp projects, each with adefaultscript that writes a unique marker. Callrun()whileprocess.cwd()is project A (no explicitcwdoption). Then changeprocess.cwd()to project B before callingnext(). Assert the generator executes project A's script, not project B's — provingcwdwas snapshotted atrun()call time. (Spec 9.1, 9.5) - T-API-09a: Manual iterator cancellation during a pending
next(). Use thewrite-pid-to-filefixture (a TS script that writes its PID to a marker file, writes"ready"to stderr, then blocks). Obtain the async iterator, calliterator.next()to start the first iteration. Wait for the child to be ready (e.g., poll the marker file or observe stderr). Then calliterator.return()whilenext()is still pending. Assert: (1) the child process / process group is terminated (read PID from marker file, verify it is no longer running), and (2) the generator completes with no further yields. (Spec 9.1) - T-API-09c:
run()options snapshot — mutatingmaxIterationsafter call. Create aRunOptionsobject withmaxIterations: 2. Callrun("myscript", opts)to obtain the generator. Then mutateopts.maxIterations = 100. Iterate the generator to completion. Assert exactly 2 outputs are yielded — proving the options were snapshotted atrun()call time and the mutation had no effect. (Spec 9.1)
- T-API-10:
run("myscript", { signal })— aborting the signal terminates the loop and the generator throws an abort error. (Spec 9.5) - T-API-10a:
run("myscript", { signal })— aborting the signal while a child process is actively running terminates the child process group. Use thewrite-pid-to-filefixture. Wait for the child to write its PID to the marker file and emit"ready"on stderr. Abort the signal. Assert: (1) the generator throws an abort error, and (2) the PID from the marker file is no longer running. (Spec 9.5, 9.1) - T-API-10b: Pre-aborted signal. Create an
AbortController, callcontroller.abort()immediately, then callrun("myscript", { signal: controller.signal }). On the firstnext(), the generator throws an abort error. No child process is spawned (use a counter file to verify no script ran). (Spec 9.5, 9.1) - T-API-10c: Signal aborted between iterations (no active child). Use a script that emits a result with no
goto(loop resets). Collect the first yield, then abort the signal before callingnext()again. The nextnext()call throws an abort error. No further iterations execute. (Spec 9.5, 9.1)
- T-API-11:
runPromise("myscript", { maxIterations: 3 })resolves with an array of 3Outputobjects. (Spec 9.2) - T-API-12:
runPromise()resolves whenstop: trueis output. (Spec 9.2) - T-API-13:
runPromise()rejects when a script exits non-zero. (Spec 9.3) - T-API-14:
runPromise("myscript", { maxIterations: 3, envFile: "local.env", cwd: project.dir })resolves with correct outputs using all option fields. The env file variable is visible to the script (via marker file),cwdis respected (script runs in the correct project), and exactly 3 iterations are collected. (Spec 9.2, 9.5) - T-API-14a:
runPromise()with no script name runs thedefaultscript. (Spec 9.2, 9.1) - T-API-14b:
runPromise("myscript", { maxIterations: 0 })resolves with an empty array[]. (Spec 9.2, 9.5) - T-API-14c:
runPromise()cwd snapshot timing. Create two temp projects, each with adefaultscript that writes a unique marker. CallrunPromise()whileprocess.cwd()is project A (no explicitcwdoption). Changeprocess.cwd()to project B before the promise resolves. Assert the promise resolves with outputs from project A's script, not project B's — provingcwdwas snapshotted atrunPromise()call time. (Spec 9.2, 9.5) - T-API-14d:
runPromise()options snapshot — mutatingmaxIterationsafter call. Create aRunOptionsobject withmaxIterations: 2. CallrunPromise("myscript", opts)to obtain the promise. Then mutateopts.maxIterations = 100. Await the promise. Assert exactly 2 outputs are returned — proving the options were snapshotted atrunPromise()call time. (Spec 9.2, 9.1) - T-API-14e:
runPromise("nonexistent", { maxIterations: 0 })rejects because the script does not exist. Even withmaxIterations: 0, validation (discovery, script resolution) runs before the zero-iteration short-circuit. This mirrors CLI-n 0behavior (T-CLI-19). (Spec 9.2, 9.5, 7.1)
- T-API-15: Programmatic API never prints
resultto stdout. UserunAPIDriver()to spawn a driver process that callsrun()and collects outputs. Assert the driver's stdout contains only the driver's own JSON output — noresultvalues from scripts leak to stdout. (Spec 9.3) - T-API-16: Non-zero script exit causes
run()generator to throw. (Spec 9.3) - T-API-17: Invalid goto target causes
run()generator to throw. (Spec 9.3) - T-API-18: Script stderr is forwarded to the calling process's stderr. (Spec 9.3)
- T-API-19: When
run()throws, previously yielded outputs are preserved (the caller already consumed them). Test: collect outputs in an array, handle the throw, verify array has the partial results. (Spec 9.3) T-API-20: Removed. "Partial outputs are not available" fromrunPromise()rejection is not meaningfully observable beyond the promise rejecting. The relevant surface is already covered by T-API-13 (rejection on non-zero exit), T-API-15 (no stdout leakage), and T-API-19 (partial outputs preserved withrun()). (Spec 9.3)- T-API-20a:
run("nonexistent")—run()returns a generator without throwing. On the firstnext(), the generator throws because the script does not exist. No child process is spawned (use a counter file in.loopx/to verify no script ran). (Spec 9.1, 9.3) - T-API-20b:
runPromise("nonexistent")— rejects because the script does not exist. (Spec 9.3) - T-API-20c:
run("myscript")with.loopx/containing a name collision —run()returns a generator. On the firstnext(), the generator throws (validation failure). No child process is spawned. (Spec 9.1, 9.3) - T-API-20d:
run("myscript", { envFile: "nonexistent.env" })—run()returns a generator. On the firstnext(), the generator throws because the env file does not exist. No child process is spawned. (Spec 9.1, 9.3, 9.5) - T-API-20e:
runPromise("myscript", { envFile: "nonexistent.env" })— rejects because the env file does not exist. (Spec 9.3, 9.5) - T-API-20f:
run("myscript", { cwd: dirWithoutLoopx })—run()returns a generator without throwing. On the firstnext(), the generator throws because.loopx/does not exist in the specifiedcwd. (Spec 9.1, 9.3) - T-API-20g:
runPromise("myscript", { cwd: dirWithoutLoopx })— rejects because.loopx/does not exist. (Spec 9.3) - T-API-20h:
run(undefined, { cwd: dirWithLoopxButNoDefault })—run()returns a generator. On the firstnext(), the generator throws because nodefaultscript exists. (Spec 9.1, 9.3) - T-API-20i:
runPromise(undefined, { cwd: dirWithLoopxButNoDefault })— rejects because nodefaultscript exists. (Spec 9.3)
- T-API-21:
run("myscript", { envFile: "local.env" })loads the env file. Script sees the variables. (Spec 9.5) - T-API-21a:
run("myscript", { envFile: "relative/path.env", cwd: "/some/dir" })— relative envFile path is resolved against the providedcwd. (Spec 9.5) - T-API-21b:
run("myscript", { envFile: "relative/path.env" })with nocwdoption — relative envFile path is resolved againstprocess.cwd()at call time. Set up a temp project, place the env file at a relative path fromprocess.cwd(), and verify the script sees the variables. (Spec 9.5) - T-API-21c: Env file parse warnings are forwarded to stderr via the programmatic API. Use
runAPIDriver()to spawn a driver that callsrun("myscript", { envFile: "local.env" })wherelocal.envcontains a malformed line (e.g.,justtextwith no=). Assert the driver process's stderr contains a warning about the invalid line. This verifies that local env parse warnings reach stderr in the programmatic API path, not just the CLI path. (Spec 8.1, 9.3) - T-API-21d: Global env file parse warnings are forwarded to stderr via the programmatic API. Set up
XDG_CONFIG_HOMEto a temp directory with a global env file containing a malformed line (e.g.,1BAD=val). UserunAPIDriver()to callrun("myscript"). Assert the driver process's stderr contains a warning about the invalid key. (Spec 8.1, 9.3)
- T-API-22:
run("myscript", { maxIterations: -1 })→run()returns a generator. On the firstnext(), the generator throws (non-negative integer required). No script is executed. (Spec 9.1, 9.5) - T-API-23:
run("myscript", { maxIterations: 1.5 })→run()returns a generator. On the firstnext(), the generator throws (non-integer). No script is executed. (Spec 9.1, 9.5) - T-API-23a:
run("myscript", { maxIterations: NaN })→run()returns a generator. On the firstnext(), the generator throws (NaN is not a valid non-negative integer). No script is executed. (Spec 9.1, 9.5) - T-API-24:
runPromise("myscript", { maxIterations: NaN })→ rejects before execution. (Spec 9.5) - T-API-24a:
runPromise("myscript", { maxIterations: -1 })→ rejects before execution (negative values are invalid). (Spec 9.5) - T-API-24b:
runPromise("myscript", { maxIterations: 1.5 })→ rejects before execution (non-integer values are invalid). (Spec 9.5)
- T-API-25:
runPromise("myscript", { signal })— aborting the signal terminates the loop and the promise rejects with an abort error. (Spec 9.5) - T-API-25a:
runPromise("myscript", { signal })with pre-aborted signal — the promise rejects immediately with an abort error. No child process is spawned. (Spec 9.5) - T-API-25b:
runPromise("myscript", { signal, maxIterations: 3 })— abort signal between iterations. Use a script that completes quickly (nogoto, nostop). Set up theAbortControllerand abort the signal after a short delay (enough for one iteration to complete but before the loop finishes all 3). Assert the promise rejects with an abort error. SincerunPromiseis an all-or-nothing API, the rejection occurs even though some iterations completed internally. (Spec 9.5, 9.2)
Spec refs: 10.1–10.3
All install tests use local servers (HTTP, file:// git repos). No network access.
- T-INST-01:
loopx install myorg/my-scriptis treated as a git source (github shorthand). Expands tohttps://github.com/myorg/my-script.git. Verify by usingwithGitURLRewriteto redirecthttps://github.com/myorg/my-script.gitto a local bare repo, and asserting that the repo is cloned into.loopx/my-script/. (Spec 10.1) - T-INST-01a:
loopx install myorg/my-script.git→ error, exit code 1. The<repo>segment of the shorthand must not end in.git. Users who want a.gitURL must provide the full URL. (Spec 10.1) - T-INST-02:
loopx install https://github.com/org/repo→ treated as git (known host). (Spec 10.1) - T-INST-03:
loopx install https://gitlab.com/org/repo→ treated as git. (Spec 10.1) - T-INST-04:
loopx install https://bitbucket.org/org/repo→ treated as git. (Spec 10.1) - T-INST-05:
loopx install https://example.com/repo.git→ treated as git (.git suffix). (Spec 10.1) - T-INST-06:
loopx install http://localhost:PORT/pkg.tar.gz→ treated as tarball. (Spec 10.1) - T-INST-07:
loopx install http://localhost:PORT/pkg.tgz→ treated as tarball. (Spec 10.1) - T-INST-08:
loopx install http://localhost:PORT/script.ts→ treated as single file. (Spec 10.1) - T-INST-08a:
loopx install https://github.com/org/repo/archive/main.tar.gz→ treated as tarball (not git), because the pathname has more than two segments. (Spec 10.1) - T-INST-08b:
loopx install https://github.com/org/repo/raw/main/script.ts→ treated as single file (not git), because the pathname has additional path segments. (Spec 10.1) - T-INST-08c:
loopx install https://github.com/org/repo/→ treated as git (trailing slash allowed on known host). (Spec 10.1) - T-INST-08d:
loopx install http://localhost:PORT/pkg.tar.gz?token=abc→ treated as tarball. Source detection operates on the URL pathname (ignoring query string), so the pathname/pkg.tar.gzis recognized as a tarball by its.tar.gzextension. (Spec 10.1)
- T-INST-09: Downloading a
.tsfile places it in.loopx/with the correct filename. (Spec 10.2) - T-INST-10: URL with query string
?token=abc→ query stripped from filename. (Spec 10.2) - T-INST-11: URL with fragment
#section→ fragment stripped from filename. (Spec 10.2) - T-INST-12: URL ending in unsupported extension (e.g.,
.py) → error, nothing saved. (Spec 10.2) - T-INST-13: Script name = base name of downloaded file (e.g.,
script.ts→ namescript). (Spec 10.2) - T-INST-14:
.loopx/directory created if it doesn't exist. (Spec 10.3)
- T-INST-15: Cloning a git repo places it in
.loopx/<repo-name>/. (Spec 10.2) - T-INST-16: Shallow clone (
--depth 1). The source bare repo must have more than one commit (to make the shallow assertion non-vacuous). Verify the clone has only 1 commit (git -C .loopx/<name> rev-list --count HEAD= 1). (Spec 10.2) - T-INST-17: Repo name derived from URL minus
.gitsuffix. (Spec 10.2) - T-INST-18: Repo name derived from URL without
.gitsuffix (e.g., github.com known host). (Spec 10.2) - T-INST-19: Cloned repo must have
package.jsonwithmain. If missing → clone removed, error displayed. (Spec 10.2) - T-INST-20: Cloned repo has
package.jsonwithmainpointing to unsupported extension → clone removed, error. (Spec 10.2) - T-INST-21: Successful git install → directory script is runnable via
loopx -n 1 <name>. (Spec 10.2)
- T-INST-22: Downloading and extracting a
.tar.gzfile places contents in.loopx/<archive-name>/. (Spec 10.2) - T-INST-23: Single top-level directory in archive → that directory becomes the package root (unwrapped). (Spec 10.2)
- T-INST-24: Multiple top-level entries in archive → placed directly in
.loopx/<archive-name>/. (Spec 10.2) - T-INST-25:
.tgzextension handled identically. (Spec 10.2) - T-INST-26: Extracted directory must have
package.jsonwithmain. If not → directory removed, error. (Spec 10.2) - T-INST-26a: Tarball URL with query string (e.g.,
http://localhost:PORT/pkg.tar.gz?token=abc) → query stripped from archive-name derivation. Installed directory is.loopx/pkg/, not.loopx/pkg.tar.gz?token=abc/. (Spec 10.2) - T-INST-26b: Tarball URL with fragment (e.g.,
http://localhost:PORT/pkg.tgz#v1) → fragment stripped from archive-name. (Spec 10.2)
- T-INST-27: Destination-path collision. Installing when a filesystem entry already exists at the exact destination path → error, existing entry untouched. Example:
.loopx/foo.tsexists, install a single-file URL that would also placefoo.ts→ rejected. (Spec 10.3) - T-INST-27a: Destination-path collision with non-script directory. Installing when a non-script directory with the same name already exists in
.loopx/(e.g.,.loopx/foo/exists as a shared utility directory with nopackage.json, install a git repo that would go to.loopx/foo/) → error, existing directory untouched. (Spec 10.3) - T-INST-27b: Script-name collision across different path types.
.loopx/foo.shexists (file script namedfoo), install a git repo that would clone to.loopx/foo/— different destination path, but same derived script namefoo. → error, existing script untouched, no.loopx/foo/directory created. (Spec 10.3, 5.2) - T-INST-27c: Script-name collision across different file extensions.
.loopx/foo.tsexists (file script namedfoo), install a single-file URL forfoo.sh→ error. Both resolve to script namefoo. The existing.loopx/foo.tsis untouched and no.loopx/foo.shis created. (Spec 10.3, 5.2) - T-INST-27d: Script-name collision when target name already has a pre-existing collision in
.loopx/. Setup:.loopx/foo.shand.loopx/foo.tsboth exist (pre-existing name collision onfoo). Attempt to install a git repo that would clone to.loopx/foo/. Assert: exit code 1, error on stderr mentioning name collision, no.loopx/foo/directory created, both.loopx/foo.shand.loopx/foo.tsuntouched. This is distinct from T-INST-27b/27c: those test single-entry collisions where the name is in the scripts map; this tests that install detects the name even when it is already involved in a pre-existing collision (and therefore excluded from the scripts map during discovery). (Spec 10.3, 5.2) - T-INST-28: Installing a script with a reserved name (e.g.,
output.ts) → error, nothing saved. (Spec 10.3) - T-INST-29: Installing a script with invalid name (e.g.,
-invalid.ts) → error, nothing saved. (Spec 10.3) - T-INST-30: No automatic
npm install/bun installafter clone/extract. Verifynode_modules/does not appear in installed directory script. (Spec 10.3) - T-INST-31: HTTP 404 during single-file download → error, exit code 1, no partial file left in
.loopx/. (Spec 10.3) - T-INST-31b: Single-file install write failure cleans up partial file. Serve a valid
.tsfile via the local HTTP server, then make.loopx/directory read-only (chmod 0o555) before running install so the download succeeds butwriteFileSyncfails. Assert: exit code 1, error on stderr, no partial file left at the destination path in.loopx/. This test is conditional onprocess.getuid() !== 0— root can write to any directory. (Spec 10.3) - T-INST-32: Git clone failure (non-existent repo) → error, exit code 1, no partial directory left in
.loopx/. (Spec 10.3) - T-INST-33: Tarball extraction failure (corrupt archive) → error, exit code 1, no partial directory left in
.loopx/. (Spec 10.3) - T-INST-33a: Empty tarball (valid
.tar.gzwith no entries). Serve a valid but empty tarball via the local HTTP server.loopx install <url>→ exit code 1, stderr contains error about the empty archive, no partial directory left in.loopx/. An empty tarball is an extraction failure — it produces zero usable entries. (Spec 10.3)
These tests verify that git/tarball installs apply the same validation as discovery (Spec 5.1) to the resulting directory.
- T-INST-34: Git install where cloned repo has invalid JSON in
package.json→ clone removed, error, exit code 1, no partial directory left in.loopx/. (Spec 10.2, 5.1) - T-INST-35: Git install where cloned repo has
package.jsonwith non-stringmain(e.g.,{"main": 42}) → clone removed, error, exit code 1. (Spec 10.2, 5.1) - T-INST-36: Git install where cloned repo has
package.jsonwithmainescaping the directory (e.g.,{"main": "../escape.ts"}) → clone removed, error, exit code 1. (Spec 10.2, 5.1) - T-INST-37: Git install where cloned repo has
package.jsonwithmainpointing to a file that does not exist → clone removed, error, exit code 1. (Spec 10.2, 5.1) - T-INST-38: Tarball install where extracted directory has invalid JSON in
package.json→ directory removed, error, exit code 1. (Spec 10.2, 5.1) - T-INST-39: Tarball install where extracted directory has
package.jsonwithmainpointing to a missing file → directory removed, error, exit code 1. (Spec 10.2, 5.1) - T-INST-39a: Tarball install where extracted directory has
package.jsonwith non-stringmain(e.g.,{"main": 42}) → directory removed, error, exit code 1. (Spec 10.2, 5.1) - T-INST-39b: Tarball install where extracted directory has
package.jsonwithmainescaping the directory (e.g.,{"main": "../escape.ts"}) → directory removed, error, exit code 1. (Spec 10.2, 5.1) - T-INST-39c: Tarball install where extracted directory has
package.jsonwithmainpointing to unsupported extension (e.g.,{"main": "index.py"}) → directory removed, error, exit code 1. (Spec 10.2, 5.1) - T-INST-39d: Git install where cloned repo has
package.jsonwithmainpointing to a symlink that resolves outside the directory boundary (e.g.,entry.tsis a symlink to../../outside.ts). Assert: exit code 1, error on stderr referencing the symlink/boundary violation, clone is fully removed from.loopx/. Install post-validation must apply the same symlink boundary check as discovery (Spec 5.1: "must still resolve to a path within the script's directory after symlink resolution"). (Spec 10.2, 5.1) - T-INST-39e: Tarball install where extracted directory has
package.jsonwithmainpointing to a symlink that resolves outside the directory boundary. Same assertions as T-INST-39d but for tarball installs: exit code 1, error on stderr, extracted directory removed. (Spec 10.2, 5.1)
- T-INST-GLOBAL-01: Full global install lifecycle.
npm packthe built loopx package, install the resulting tarball into an isolated global prefix (usingnpm install -g --prefix <tempdir>), create a fixture project with a.loopx/default.tsscript, run<tempdir>/bin/loopx -n 1against the fixture project, and assert the script ran (via marker file) and exit code is 0. This exercises the full Spec 3.1 workflow: global binary on PATH, module resolution forimport from "loopx", and script execution. (Spec 3.1) - T-INST-GLOBAL-01a:
[Bun]Full global install lifecycle under Bun. Same workflow as T-INST-GLOBAL-01 but the fixture script is run via Bun: install globally, create a fixture project with a.loopx/default.tsscript that usesimport { output } from "loopx", invoke the globalloopxbinary under Bun (bun <global-bin>/loopx -n 1). Assert the script ran (via marker file) andimport from "loopx"resolved correctly. This exercises theNODE_PATH-based module resolution path that Bun uses for global installs (Spec 3.3). (Spec 3.1, 3.3)
Spec refs: 7.3
Signal tests use the signal-ready-then-sleep, signal-trap-exit, signal-trap-ignore, and spawn-grandchild fixtures. All signal fixtures follow the ready-protocol: write PID to marker file, write "ready" to stderr, then block. Tests use waitForStderr("ready") to synchronize before sending signals.
- T-SIG-01: Send SIGINT to loopx while a script is running. Use
signal-ready-then-sleepfixture.waitForStderr("ready"), then send SIGINT. Assert loopx exits with code 130 (128 + 2). (Spec 7.3) - T-SIG-02: Send SIGTERM to loopx while a script is running. Use
signal-ready-then-sleepfixture.waitForStderr("ready"), then send SIGTERM. Assert loopx exits with code 143 (128 + 15). (Spec 7.3) - T-SIG-03: After SIGINT, the child script process is no longer running. Use
signal-ready-then-sleepfixture which writes PID to marker file. After loopx exits, read the PID from the marker file and verify the process is gone (e.g.,kill(pid, 0)throws). (Spec 7.3) - T-SIG-04: Grace period: child script traps SIGTERM and exits within 2 seconds. Use
signal-trap-exit(markerPath, 2)fixture.waitForStderr("ready"), send SIGTERM. Assert loopx exits with code 128+15 (no SIGKILL needed — child exited within grace period). (Spec 7.3) - T-SIG-05: Grace period exceeded: child script traps SIGTERM and hangs (ignores it). Use
signal-trap-ignore(markerPath)fixture.waitForStderr("ready"), send SIGTERM. Assert loopx sends SIGKILL after ~5 seconds and exits. Verify the child PID (from marker file) is no longer running. (Spec 7.3) - T-SIG-06: Process group signal: script spawns a grandchild process. Use
spawn-grandchild(markerPath)fixture.waitForStderr("ready"), send SIGTERM to loopx. Read both PIDs from marker file. Assert both the script and grandchild are no longer running after loopx exits. (Spec 7.3) - T-SIG-07: Between-iterations signal: Use a script that outputs no
gotoorstop(so the loop resets) and writes a ready marker to a known file after each iteration. The test sends SIGTERM between iterations by coordinating via the marker file. Assert loopx exits immediately with code 143 (128 + 15). This test may require a small sleep or poll to hit the between-iterations window, so it is tagged as@flaky-retry(3)to tolerate occasional timing misses. (Spec 7.3) - T-SIG-08: The child process receives the same signal that was sent to loopx (forwarding preserves signal identity). Use a bash fixture that traps both SIGINT and SIGTERM separately:
trap 'printf SIGINT > $MARKER' INT; trap 'printf SIGTERM > $MARKER' TERM. The script writes its PID to a separate marker file and emits"ready"to stderr, then sleeps. (a) Send SIGINT to loopx. Read the signal marker file. Assert it contains"SIGINT". (b) Repeat with SIGTERM and assert"SIGTERM". This ensures loopx forwards the original signal to the child process group, not a generic SIGTERM for all cases. (Spec 7.3)
Spec refs: 3.2
Delegation tests create the following structure:
/tmp/test-project/
node_modules/
.bin/
loopx → symlink or wrapper script pointing to a "local" build
.loopx/
marker.sh → script that writes LOOPX_BIN to a file
The "global" binary is the primary build. The "local" binary is a separate build or a wrapper that sets a marker.
- T-DEL-01: When
node_modules/.bin/loopxexists in CWD, the global binary delegates to it. Verify by having the local binary write a marker file. (Spec 3.2) - T-DEL-02: When
node_modules/.bin/loopxexists in an ancestor directory (e.g.,../node_modules/.bin/loopx), delegation finds it. (Spec 3.2) - T-DEL-03: Nearest ancestor wins. Create local installs at both CWD and parent. Assert CWD's version is used. (Spec 3.2)
- T-DEL-04:
LOOPX_DELEGATED=1in environment prevents delegation. Even ifnode_modules/.bin/loopxexists, the global binary runs. (Spec 3.2) - T-DEL-05: After delegation,
LOOPX_BINcontains the resolved realpath of the local binary (not the global one, not a symlink). (Spec 3.2) - T-DEL-06: After delegation,
import from "loopx"in scripts resolves to the local (delegated-to) version's package, not the global version. The local version must be observably distinct — e.g., it includes an additional non-standard export (like__loopxVersion) or writes a distinctive marker during module initialization. A TS script imports from"loopx"and checks for the local version's marker. Assert that the script observes the local version's marker, not the global's. (Spec 3.2) - T-DEL-07: Delegation sets
LOOPX_DELEGATED=1in the delegated process. UsewithDelegationSetup. The local binary is a script that writes$LOOPX_DELEGATEDto a marker file viaobserve-env. Assert the marker file contains{ "present": true, "value": "1" }. This verifies the environment variable is actually received, not just that the recursion guard works. (Spec 3.2) - T-DEL-08: Delegation happens before command handling. Use
withDelegationSetup. Run the global binary withversionas the argument. The local binary writes a marker file on invocation. Assert: (1) the marker file exists (proving the local binary was invoked), and (2) the local binary's version output is observed (not the global's). This verifies delegation occurs before theversionsubcommand is processed. (Spec 3.2) - T-DEL-09:
LOOPX_DELEGATED=""(empty string) in environment prevents delegation. The spec says delegation is skipped whenLOOPX_DELEGATEDis "set" — in POSIX semantics, an empty string is "set." UsewithDelegationSetup, spawn the global binary withenv: { LOOPX_DELEGATED: "" }. Assert the local binary's marker file does NOT exist (the global binary ran directly, delegation was skipped). (Spec 3.2) - T-DEL-10: Delegation preserves SIGINT exit code. Use
withDelegationSetupwith a.loopx/fixture containing asignal-ready-then-sleepscript. Spawn the global binary (which delegates to local).waitForStderr("ready"), then send SIGINT to the global binary process. Assert the global binary exits with code 130 (128 + 2). (Spec 3.2, 7.3, 12) - T-DEL-11: Delegation preserves SIGTERM exit code. Same setup as T-DEL-10, but send SIGTERM instead. Assert the global binary exits with code 143 (128 + 15). (Spec 3.2, 7.3, 12)
Spec refs: 12
These tests consolidate exit code assertions. Many are also verified in other sections, but this section ensures completeness. Note: T-EXIT-01 through T-EXIT-04 are redundant smoke checks — they overlap with stronger originating tests (T-LOOP-04, T-LOOP-07, T-CLI-15, T-CLI-01) and are expected to pass against the stub. They exist for cross-cutting traceability, not as primary contract tests.
- T-EXIT-01: Clean exit via
stop: true→ code 0. (Spec 12) (Redundant with T-LOOP-04) - T-EXIT-02: Clean exit via
-nlimit reached → code 0. (Spec 12) (Redundant with T-LOOP-07) - T-EXIT-03: Clean exit via
-n 0→ code 0. (Spec 12) (Redundant with T-CLI-15) - T-EXIT-04: Successful subcommand (
loopx version) → code 0. (Spec 12) (Redundant with T-CLI-01) - T-EXIT-05: Script exits non-zero → code 1. (Spec 12)
- T-EXIT-06: Validation failure (name collision) → code 1. (Spec 12)
- T-EXIT-07: Invalid goto target → code 1. (Spec 12)
- T-EXIT-08: Missing script → code 1. (Spec 12)
- T-EXIT-09: Missing
.loopx/directory → code 1. (Spec 12) - T-EXIT-10: Usage error (invalid
-n) → code 1. (Spec 12) - T-EXIT-11: Missing
-efile → code 1. (Spec 12) - T-EXIT-12: SIGINT → code 130. (Spec 12)
- T-EXIT-13: SIGTERM → code 143. (Spec 12)
Fuzz tests use fast-check for property-based testing. They are designed to find edge cases that manual test enumeration misses.
File: tests/fuzz/output-parsing.fuzz.test.ts
Approach: Generate random strings and feed them as the stdout of a bash script. Verify invariants hold for all inputs.
| Generator | Description |
|---|---|
arbitraryJSON |
Random valid JSON values (objects, arrays, strings, numbers, booleans, null) |
arbitraryString |
Random strings including unicode, control characters, empty, very long |
arbitraryOutputObject |
Random objects with result, goto, stop fields of various types |
arbitraryMalformedJSON |
Strings that look like JSON but are malformed (truncated, extra commas, etc.) |
-
F-PARSE-01: No crashes. For any string written to stdout, loopx does not crash with an uncaught exception. It exits with code 0 (loop resets or stops) or code 1 (script error). Never any other exit code for non-signal cases.
-
F-PARSE-02: Deterministic parsing. The same stdout content always produces the same behavior (same exit code, same next script, same piped input). Run the same fixture twice and compare results.
-
F-PARSE-03: Type safety of parsed output. If the output is parsed as structured output (i.e., a valid JSON object with known fields), then:
result, if present, is always a string (coercion applied).goto, if present, is always a string (invalid types discarded).stop, if present, is alwaystrue(other values discarded).
-
F-PARSE-04: Raw fallback consistency. If the stdout is not a valid JSON object containing at least one known field, the entire stdout is treated as the
resultvalue. Verify by piping through a goto chain and checking the received value. -
F-PARSE-05: Non-ASCII safe. Stdout containing UTF-8 text with embedded NUL bytes, control characters (0x01–0x1F), and high Unicode (emoji, CJK, supplementary plane codepoints) does not cause crashes or hangs. This property does not test arbitrary binary byte sequences — the spec does not define encoding behavior for non-UTF-8 content.
For each generated input:
- Write a JS/TS script (not bash
echo) that reads the test payload from a file and writes it to stdout usingprocess.stdout.write(). This safely handles arbitrary strings including control characters and null bytes, which shellechocannot represent reliably. - Set up a two-script chain: A (the writer script) → goto B (a stdin reader). Use a wrapper script that always outputs
goto:"reader"regardless of what the inner writer produces. If the writer output overrides the goto (i.e., it's a valid structured output with its own goto or stop), the test observes that behavior instead. - Alternatively, use the programmatic API (
run()) to observe the parsed output directly. - Assert the invariants above.
Iterations: The structured output fuzzer has two tiers:
- Unit-level parser fuzzing: At least 1000 random inputs per property. These call the parser function directly (no child process), so they are fast. This is where high-volume fuzzing lives. Uses the
parseOutputinternal seam (section 1.4). - E2E fuzzing: At most 50–100 random inputs per property. Each input spawns a real child process, so high iteration counts are prohibitively slow. The E2E layer is a randomized smoke test to catch integration issues the unit fuzzer cannot.
File: tests/fuzz/env-parsing.fuzz.test.ts
Approach: Generate random .env file contents and load them via -e. Verify invariants.
| Generator | Description |
|---|---|
arbitraryEnvFile |
Random multi-line strings with valid/invalid KEY=VALUE pairs |
arbitraryEnvLine |
Single lines: valid pairs, comments, blank, malformed |
arbitraryEnvValue |
Values with special characters: quotes, #, =, spaces, unicode |
-
F-ENV-01: No crashes. For any string as
.envfile content, loopx does not crash. It either loads successfully or reports an error gracefully. -
F-ENV-02: Deterministic parsing. Same file content → same variables loaded. Run twice, compare.
-
F-ENV-03: Keys and values are strings. All loaded environment variables have string keys and string values (no type confusion).
-
F-ENV-04: Last-wins for duplicates. If a key appears multiple times, the last value is always the one seen by scripts.
-
F-ENV-05: Comment lines never produce variables. Lines starting with
#never result in environment variables being set.
Iterations: Same two-tier approach as section 5.1: at least 1000 inputs at the unit-parser level, 50–100 at the E2E level. Uses the parseEnvFile internal seam (section 1.4).
Unit tests provide fast feedback on isolated parsing/logic functions. They are NOT the primary validation strategy but add confidence.
File: tests/unit/parse-output.test.ts
Uses the parseOutput internal seam (section 1.4).
Test the parser function directly:
- Valid JSON objects with various field combinations
- Type coercion cases (result as number, goto as boolean, stop as string)
- Edge cases: empty string, whitespace-only, very large strings
- Non-object JSON values (arrays, primitives, null)
- Malformed JSON
File: tests/unit/parse-env.test.ts
Uses the parseEnvFile internal seam (section 1.4).
Test the parser function directly:
- Standard KEY=VALUE pairs
- Comments, blank lines
- Quoted values (single, double)
- Escape sequences (literal, not interpreted)
- Duplicate keys
- Inline
#characters - Edge cases:
=in values, empty values, very long values
File: tests/unit/source-detection.test.ts
Test the source classification logic (section 10.1) in isolation:
org/repo→ git (github)- Various URLs → correct source type
- Edge cases: URLs with ports, auth, paths, query strings
File: tests/unit/types.test.ts
These tests verify the public TypeScript type surface documented in Spec section 9.5. They must be validated via a real typecheck stage — not just ordinary Vitest runtime execution. A test that merely imports a type and uses it at runtime can pass vacuously if the type is any or the assertion is elided at compile time.
Required execution method (pick one):
- Vitest typecheck mode (
vitest typecheck): runs type-level assertions viaexpectTypeOfwithout executing runtime code. tsc --noEmit: a separate CI step that typechecks the test file against the built package's.d.tsfiles.tsd: a dedicated type-testing library that asserts against.d.tsexports.
Ordinary vitest run on types.test.ts is not sufficient as the sole verification. If vitest run is used, it must be paired with one of the above to ensure the type assertions are enforced at the type level.
Setup: The test file imports from the built loopx package as a real consumer would (same symlink/link approach as runAPIDriver).
- T-TYPE-01:
import type { Output, RunOptions } from "loopx"compiles without error. (Spec 9.5) - T-TYPE-02:
Outputhas optionalresult?: string,goto?: string,stop?: booleanfields — and no other required fields. Assert viaexpectTypeOf<Output>()or equivalent. (Spec 9.5) - T-TYPE-03:
RunOptionshas optionalmaxIterations?: number,envFile?: string,signal?: AbortSignal,cwd?: stringfields. (Spec 9.5) - T-TYPE-04:
run()returnsAsyncGenerator<Output>. Assert thatimport { run } from "loopx"compiles and the return type is assignable toAsyncGenerator<Output>. (Spec 9.1, 9.5) - T-TYPE-05:
runPromise()returnsPromise<Output[]>. Assert the return type is assignable toPromise<Output[]>. (Spec 9.2, 9.5) - T-TYPE-06:
run()andrunPromise()accept an optionalRunOptionssecond argument. (Spec 9.1, 9.2, 9.5) - T-TYPE-07:
run()andrunPromise()accept an optional script name as the first argument (string | undefined). (Spec 9.1, 9.2, 9.5)
These tests specifically target boundary conditions that could reveal implementation bugs.
- T-EDGE-01: Very long result string (~1 MB). Script outputs
{"result":"<1MB>"}. Assert it is handled without truncation or hang. (Spec 2.3) - T-EDGE-02: Result containing JSON-special characters (quotes, backslashes, newlines). Verify correct JSON serialization/deserialization. (Spec 2.3)
- T-EDGE-03: Script that writes stdout in multiple
write()calls (partial writes). Assert the full output is captured and parsed as a unit. (Spec 2.3) - T-EDGE-04: Script that writes to both stdout and stderr. Assert stdout captured as output, stderr passed through. No interleaving issues. (Spec 6.2, 6.3)
- T-EDGE-05: Unicode in result values and env values is preserved correctly. Unicode in script names (e.g.,
.loopx/café.sh) is rejected — script names are ASCII-only per the[a-zA-Z0-9_][a-zA-Z0-9_-]*pattern. Assert that a unicode-named script produces a validation error. (Spec 2.3, 5.4, 8.1) - T-EDGE-06: Deeply nested goto chain (A → B → C → D → E → ... → Z). Assert correct execution order and iteration counting. (Spec 7.1)
- T-EDGE-07: Script that produces output on stdout but also reads from stdin when no input is available. Assert no deadlock. (Spec 6.8)
T-EDGE-08: Moved to robustness suite. Concurrent loopx invocations — not direct spec conformance.T-EDGE-09: Moved to robustness suite. Large script count discovery performance — not direct spec conformance.T-EDGE-10: Moved to robustness suite. Maximum-length script name — filesystem limit, not direct spec conformance.- T-EDGE-11:
-nwith very large value (e.g.,999999). Assert no integer overflow or similar. Script shouldstopafter a few iterations. (Spec 4.2) - T-EDGE-12: Empty
.loopx/directory (exists but no scripts).loopx→ error (no default script).loopx myscript→ error (not found). (Spec 4.1) T-EDGE-13: Moved to robustness suite. Long-running script patience — not direct spec conformance.- T-EDGE-14: Env file with no newline at end of file. Assert last line is still parsed. (Spec 8.1)
- T-EDGE-15: Env file that is completely empty (0 bytes). Assert no error, no variables loaded. (Spec 8.1)
CI should test against:
| Runtime | Versions |
|---|---|
| Node.js | 20.6 (minimum), latest LTS, latest current |
| Bun | 1.0 (minimum), latest |
- Build: Compile/bundle loopx.
- Phase 0 (Harness): Run
tests/harness/. Fail the pipeline if any fail. - Typecheck: Run
tsc --noEmitorvitest typecheckontests/unit/types.test.tsto verify public type surface. This must run as a dedicated stage, not as part of ordinary Vitest runtime execution (see section 6.4). - Unit Tests: Run
tests/unit/. - E2E Tests: Run
tests/e2e/. Parameterized over runtime matrix. - Fuzz Tests: Run
tests/fuzz/with a CI-appropriate iteration count (e.g., 5000). - Stub Validation (optional, periodic): Run spec tests against stub binary and verify failure count hasn't decreased (tests haven't become vacuous).
| Suite | Timeout per test |
|---|---|
| Harness | 10s |
| Unit | 5s |
| E2E | 30s |
| E2E (signals) | 60s |
| Fuzz | 120s |
- Vitest runs test files in parallel by default. Each test file uses isolated temp directories, so this is safe.
- Signal tests should run serially within their file (signal timing is sensitive).
- Install tests that start local servers should share a single server instance per file.
This section tracks any tests that are blocked by unresolved spec ambiguities. If a test's assertions depend on a spec decision that has not been made, it is listed here so it cannot be accidentally treated as an authoritative failure.
All previously identified spec problems (SP-15 through SP-30) have been resolved. SP-31 (install name-collision across different destination paths) was identified and resolved during this revision.
Currently pending:
- SP-32 (SSH/SCP URL classification — commit d6cdb2c classifies all
git@-prefixed URLs as git sources, but SPEC.md section 10.1 does not mention SSH or SCP-style URLs. The five ordered source detection rules are written entirely in terms ofhttp(s)://URLs and theorg/reposhorthand. Either the spec should be updated to add a rule forgit@URLs, or the implementation should be narrowed to only handle known-host patterns consistent with Rule 2. No tests are added for this behavior until the spec ambiguity is resolved.)
Resolved during this revision:
- SP-28 (mid-loop removed/renamed script behavior — resolved: discovery caching freezes names and resolved paths; execution uses cached path; file disappearance fails at spawn time as a normal child-process launch error)
- SP-29 (CommonJS support — resolved: using
require()/ CommonJS in loopx JS/TS scripts is invalid and must fail at execution time) - SP-30 (install collision with non-script entries — resolved: loopx refuses to overwrite any existing filesystem entry at the destination path, not just discovered scripts)
- SP-31 (install name-collision across different destination paths — resolved: install always rejects when the derived script name would collide with any existing discovered script, even if the destination path differs)
Resolved in prior revisions: SP-15 (unmatched quotes → literal preserved), SP-17 (install validation → full 5.1 parity), SP-20/SP-21 (shorthand → reject .git suffix, consistent expansion), SP-22 (run() errors → lazy on first iteration), SP-23 (version format → bare string + newline), SP-24 (shadowed loopx → local wins, standard resolution), SP-25 (tarball detection → parsed URL pathname), SP-26 (cancellation → always terminate if child active), SP-27 (AbortSignal cancellation semantics — AbortSignal always throws/rejects, break/generator.return() completes silently).
Maps each SPEC.md section to the test IDs that verify it.
| Spec Section | Description | Test IDs |
|---|---|---|
| 1 | Overview (ESM-only) | T-MOD-22 |
| 2.1 | Script (file & directory) | T-DISC-01–17, T-DISC-11a, T-DISC-14a–14c, T-DISC-16a–16d, T-MOD-03a, T-EXEC-18a |
| 2.2 | Loop (state machine) | T-LOOP-01–05, T-LOOP-16–17 |
| 2.3 | Structured Output | T-PARSE-01–29, T-PARSE-12a, T-PARSE-20a, F-PARSE-01–05 |
| 3.1 | Global Install | T-INST-GLOBAL-01, T-INST-GLOBAL-01a |
| 3.2 | CLI Delegation | T-DEL-01–11 |
| 3.3 | Module Resolution | T-MOD-01–03, T-MOD-03a |
| 3.4 | Bash Script Binary Access | T-MOD-19–21 |
| 4.1 | Running Scripts | T-CLI-08–13, T-CLI-27 |
| 4.2 | Options (-n, -e, -h) | T-CLI-02–07j, T-CLI-14–22d, T-CLI-19a, T-CLI-20a–20b |
| 4.3 | Subcommands | T-SUB-01–19, T-SUB-06a–06b, T-SUB-14a–14k, T-DISC-46a–46b |
| 5.1 | Discovery | T-DISC-01–17, T-DISC-11a, T-DISC-14a–14c, T-DISC-16a–16d, T-DISC-33–38b, T-DISC-47–50 |
| 5.2 | Name Collision | T-DISC-18–21, T-CLI-22b |
| 5.3 | Reserved Names | T-DISC-22–26, T-CLI-22c |
| 5.4 | Name Restrictions | T-DISC-27–32, T-DISC-30a–30b, T-CLI-07d, T-CLI-22d, T-EDGE-05 |
| 5.5 | Validation Scope | T-DISC-39–46b, T-SUB-06, T-SUB-13, T-SUB-19 |
| 6.1 | Working Directory | T-EXEC-01–04 |
| 6.2 | Bash Scripts | T-EXEC-05–07 |
| 6.3 | JS/TS Scripts | T-EXEC-08–14 |
| 6.4 | Directory Scripts | T-EXEC-15–18, T-EXEC-18a |
| 6.5 | output() Function | T-MOD-04–14a, T-MOD-13a–13g |
| 6.6 | input() Function | T-MOD-15–18 |
| 6.7 | Input Piping | T-LOOP-11–15 |
| 6.8 | Initial Input | T-LOOP-14 |
| 7.1 | Basic Loop | T-LOOP-01–10, T-LOOP-25 |
| 7.2 | Error Handling | T-LOOP-18–24, T-LOOP-18a, T-DISC-38a–38b |
| 7.3 | Signal Handling | T-SIG-01–08 |
| 8.1 | Global Env Storage | T-ENV-01–15f, T-ENV-05a–05e, T-ENV-25–25a, F-ENV-01–05 |
| 8.2 | Local Env Override | T-ENV-16–19, T-ENV-17a, T-ENV-25a |
| 8.3 | Env Injection Precedence | T-ENV-20–24, T-ENV-20a, T-ENV-21a, T-ENV-24a, T-ENV-24b |
| 9.1 | run() | T-API-01–09c, T-API-08a, T-API-10–10c, T-TYPE-04, T-TYPE-06–07 |
| 9.2 | runPromise() | T-API-11–14e, T-API-25–25b, T-TYPE-05–07 |
| 9.3 | API Error Behavior | T-API-15–19, T-API-20a–20i, T-API-21c–21d |
| 9.4 | output() and input() (script-side) | T-MOD-04–14a, T-MOD-13a–13g (output()), T-MOD-15–18 (input()) — these are the same tests listed under 6.5/6.6; 9.4 references them |
| 9.5 | Types / RunOptions | T-API-07–08, T-API-10–10c, T-API-20d–20e, T-API-21–21b, T-API-22–25b, T-API-23a, T-API-24a–24b, T-TYPE-01–07 |
| 10.1 | Source Detection | T-INST-01–01a, T-INST-02–08d |
| 10.2 | Source Type Details | T-INST-09–26b, T-INST-34–39e |
| 10.3 | Common Install Rules | T-INST-27–27d, T-INST-28–33a, T-INST-31b |
| 11 | Help | T-CLI-02–07j |
| 12 | Exit Codes | T-EXIT-01–13 |