Start factoring out code from initial spike

This commit is contained in:
Willem van den Ende 2026-06-28 11:12:09 +02:00
parent 2dfafe23d6
commit f521fb3da9
11 changed files with 2494 additions and 1685 deletions

4
.gitignore vendored
View File

@ -1,3 +1,7 @@
node_modules
.pnpm-store
.idea
*.log
# Pi extension package
pi-test-status/node_modules

View File

@ -1,44 +0,0 @@
import { isBashToolResult, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function testStatusExtension(pi: ExtensionAPI) {
// After every tool execution, check if tests just ran
pi.on("tool_result", async (event, ctx) => {
// Only care about bash commands
if (!isBashToolResult(event)) return;
const command: string = event.input.command;
// Only care about test commands
if (!command.includes("npm test") && !command.includes("vitest") && !command.includes("npx vitest")) {
return;
}
const output = event.content[0]?.text ?? "";
const theme = ctx.ui.theme;
// Detect pass/fail from vitest summary output
const testsPassed = /Tests\s+.+passed/.test(output) && !/Tests\s+.+failed/.test(output);
const testsFailed = /Tests\s+.+failed/.test(output) || event.details?.exitCode !== 0;
if (testsPassed) {
// Extract summary line for inline detail (e.g., "1 passed (242ms)")
const summaryLine = output
.split("\n")
.find((line: string) => /Test Files\s+.+passed/.test(line));
const summary = summaryLine?.trim() ?? "All tests passed";
ctx.ui.setWidget("test-status", [
`${theme.fg("success", "●")} ${theme.fg("text", summary)}`,
]);
} else if (testsFailed) {
const summaryLine = output
.split("\n")
.find((line: string) => /Tests\s+.+failed/.test(line));
const summary = summaryLine?.trim() ?? "Tests failed";
ctx.ui.setWidget("test-status", [
`${theme.fg("error", "●")} ${theme.fg("text", summary)}`,
]);
}
});
}

View File

@ -0,0 +1,91 @@
# Test Output Parsers
## Motivation
The test-status widget needs to decide pass/fail and show a meaningful summary.
Relying on output regex alone for the pass/fail decision is brittle — different
test frameworks, locales, custom reporters, or unusual test names can produce
false positives or negatives. Using the process exit code as the canonical
signal is universal and correct across all tools.
This document outlines a modular architecture where pass/fail is decided by exit
code, and output parsing is purely cosmetic enrichment.
## Architecture
```
┌─────────────────────────────┐
│ Decision (exit code) │ ← single source of truth for pass/fail
│ - Always runs │
│ - Produces: pass/fail bool │
└──────────┬──────────────────┘
│ feeds status icon
┌─────────────────────────────┐
│ Display │
│ - Icon from exit code │
│ - Summary from parser │
└──────────┬──────────────────┘
│ enriched by
┌─────────────────────────────┐
│ Parser Registry │ ← extensible, tool-agnostic
│ - Tries parsers in order │
│ - First match wins │
│ - Each parser has: │
│ • detect(output) → bool │
│ • summarize(output) │
│ → str | null │
│ - Fallback: generic text │
└─────────────────────────────┘
```
## Parser Module Interface
Each parser is a self-contained module that exports two functions:
| Function | Signature | Role |
|---|---|---|
| `detect` | `(output: string) => boolean` | Returns `true` if this tool's output is present. Checks for distinctive markers (e.g., vitest's "RUN" banner, pytest's `===` separator, cargo test's "running" lines). |
| `summarize` | `(output: string) => string \| null` | Extracts a one-line human-readable summary (e.g., "3 passed (242ms)", "2 failed, 1 passed"). Returns `null` if no summary can be extracted. |
## Lifecycle
1. A tool execution completes (any bash command).
2. Check if the command is a known test command (current behaviour).
3. **Decision**: Read `event.details?.exitCode``0` means pass, anything else
means fail. This is the single source of truth.
4. **Display — icon**: Set widget icon (● green or ● red) based on exit code.
5. **Display — summary**: Walk the parser registry. For each parser, call
`detect(output)`. On first match, call `summarize(output)` for the text. If
no parser matches or `summarize` returns `null`, fall back to a generic
string ("All tests passed" / "Tests failed").
## Adding a New Tool
To add support for a new test framework, create a new file in the parsers
directory that implements the two-function interface. Register it in the
registry list. Zero changes to the core decision or display logic.
Examples of tool-specific markers for `detect`:
| Tool | Detect marker |
|---|---|
| Vitest | `" RUN "` banner |
| Jest | `"PASS "` / `"FAIL "` prefixes |
| pytest | `"=== test session starts ==="` |
| Go test | `"ok "` or `"FAIL "` line prefixes |
| cargo test | `"running "` followed by digit |
| Mocha | `"passing"` / `"failing"` in summary |
| RSpec | `"Finished in "` |
## Benefits Over Current Approach
| Aspect | Current (inline regex) | Proposed (modular parsers) |
|---|---|---|
| Pass/fail signal | Regex on output — brittle | Exit code — universal & correct |
| Output parsing | Inline, single format | Registry of modular parsers |
| Adding new tools | Edit the one function, risk regressions | Add a new file, register it |
| False negatives | Possible (word "failed" in test name) | Eliminated (exit code is canonical) |
| False positives | Possible (weird output formatting) | Eliminated |
| Parsing failure impact | Can break pass/fail (wrong icon) | Only loses cosmetic detail (correct icon preserved) |

1639
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,18 @@
{
"name": "red-green-refactor-cycle-socrates-uk",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"dependencies": {
"@earendil-works/pi-coding-agent": "^0.80.2"
}
}
}

57
pi-test-status/README.md Normal file
View File

@ -0,0 +1,57 @@
# pi-test-status
A [Pi](https://pi.dev) extension that watches for test runs and shows a pass/fail widget in the Pi UI.
## Features
- Detects `vitest` and `npm test` commands in bash tool calls
- Parses vitest output to determine pass/fail status
- Shows a live widget indicator (● green for pass, ● red for fail)
- Displays the summary line from the test output
## Installation
```bash
pi install npm:pi-test-status
```
Or with a scope:
```bash
pi install npm:@your-scope/pi-test-status
```
## Development
```bash
pi -e ./extensions/index.ts
```
Or from the package root:
```bash
pi -e .
```
## Usage
Once installed, the extension automatically activates when you run tests:
```bash
# In Pi, just run your tests — the widget appears automatically
npm test
npx vitest
vitest run
```
The widget shows:
- Green ● with summary on test pass
- Red ● with summary on test failure
## How it works
The extension subscribes to the `tool_result` event. When a bash tool call matches a test command, it parses the vitest output for pass/fail indicators and updates a Pi widget with the result.
## License
MIT

View File

@ -0,0 +1,43 @@
import {type ExtensionAPI, isBashToolResult} from "@earendil-works/pi-coding-agent";
import {isTestCommand} from "./parsing/typescript-command-output";
// noinspection JSUnusedGlobalSymbols
export default function testStatusExtension(pi: ExtensionAPI) {
// After every tool execution, check if tests just ran
pi.on("tool_result", async (event, ctx) => {
// Only care about bash commands
if (!isBashToolResult(event)) return;
if (!isTestCommand(event.input.command as string) ) return;
const textContent = event.content.find((c): c is { type: "text"; text: string } => c.type === "text");
const output = textContent?.text ?? "";
const theme = ctx.ui.theme;
// Detect pass/fail from vitest summary output
const testsPassed =
/Tests\s+.+passed/.test(output) && !/Tests\s+.+failed/.test(output);
const testsFailed =
/Tests\s+.+failed/.test(output) || event.details?.exitCode !== 0;
if (testsPassed) {
// Extract summary line for inline detail (e.g., "1 passed (242ms)")
const summaryLine = output
.split("\n")
.find((line: string) => /Test Files\s+.+passed/.test(line));
const summary = summaryLine?.trim() ?? "All tests passed";
ctx.ui.setWidget("test-status", [
`${theme.fg("success", "●")} ${theme.fg("text", summary)}`,
]);
} else if (testsFailed) {
const summaryLine = output
.split("\n")
.find((line: string) => /Tests\s+.+failed/.test(line));
const summary = summaryLine?.trim() ?? "Tests failed";
ctx.ui.setWidget("test-status", [
`${theme.fg("error", "●")} ${theme.fg("text", summary)}`,
]);
}
});
}

View File

@ -0,0 +1,3 @@
export const isTestCommand = (command: string) => {
return command.includes("npm test") || command.includes("vitest")
}

View File

@ -0,0 +1,22 @@
{
"name": "pi-test-status",
"version": "0.1.0",
"description": "Pi extension that shows test status (pass/fail) as a widget after running tests",
"keywords": ["pi-package", "pi-extension", "test-status", "vitest"],
"type": "module",
"main": "extensions/index.ts",
"files": [
"extensions",
"README.md"
],
"scripts": {
"test": "echo \"No tests yet\""
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*"
},
"pi": {
"extensions": ["./extensions"]
},
"license": "MIT"
}

2264
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,3 @@
packages:
- "."
- "pi-test-status"