Start factoring out code from initial spike
This commit is contained in:
parent
2dfafe23d6
commit
f521fb3da9
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,3 +1,7 @@
|
||||
node_modules
|
||||
.pnpm-store
|
||||
.idea
|
||||
*.log
|
||||
|
||||
# Pi extension package
|
||||
pi-test-status/node_modules
|
||||
|
||||
@ -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)}`,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
91
doc/test-output-parsers.md
Normal file
91
doc/test-output-parsers.md
Normal 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
1639
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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
57
pi-test-status/README.md
Normal 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
|
||||
43
pi-test-status/extensions/index.ts
Normal file
43
pi-test-status/extensions/index.ts
Normal 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)}`,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export const isTestCommand = (command: string) => {
|
||||
return command.includes("npm test") || command.includes("vitest")
|
||||
}
|
||||
22
pi-test-status/package.json
Normal file
22
pi-test-status/package.json
Normal 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
2264
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- "."
|
||||
- "pi-test-status"
|
||||
Loading…
x
Reference in New Issue
Block a user