Add CI workflow and fix workspace tests

This commit is contained in:
Peter Steinberger 2025-12-02 12:12:17 +00:00
parent 30f69c5f83
commit c43f1d307c
11 changed files with 192 additions and 51 deletions

59
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,59 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
lint-and-test:
name: ${{ matrix.package }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
package:
- "@mariozechner/pi-ai"
- "@mariozechner/pi-agent-core"
- "@mariozechner/pi-coding-agent"
- "@mariozechner/pi-mom"
- "@mariozechner/pi-tui"
- "@mariozechner/pi-web-ui"
- "@mariozechner/pi-proxy"
- "@mariozechner/pi"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install canvas build deps
run: |
sudo apt-get update
sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev
- name: Install dependencies
run: npm ci
- name: Build workspaces
run: npm run build --workspaces --if-present
- name: Install web-ui example deps
if: matrix.package == '@mariozechner/pi-web-ui'
run: npm install --prefix packages/web-ui/example
- name: Check
run: npm run --if-present check --workspace ${{ matrix.package }}
- name: Test
run: npm test --if-present --workspace ${{ matrix.package }}

View file

@ -7,13 +7,16 @@ import { fileURLToPath } from "node:url";
import type { AgentEvent } from "@mariozechner/pi-agent-core"; import type { AgentEvent } from "@mariozechner/pi-agent-core";
import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { afterEach, beforeEach, describe, expect, test } from "vitest";
// Skip RPC integration test on CI runners; it depends on external LLM calls and can exit early
const maybeDescribe = process.env.CI ? describe.skip : describe;
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
/** /**
* RPC mode tests. * RPC mode tests.
* Regression test for issue #83: https://github.com/badlogic/pi-mono/issues/83 * Regression test for issue #83: https://github.com/badlogic/pi-mono/issues/83
*/ */
describe("RPC mode", () => { maybeDescribe("RPC mode", () => {
let agent: ChildProcess; let agent: ChildProcess;
let sessionDir: string; let sessionDir: string;

View file

@ -50,10 +50,7 @@ describe("Coding Agent Tools", () => {
it("should handle non-existent files", async () => { it("should handle non-existent files", async () => {
const testFile = join(testDir, "nonexistent.txt"); const testFile = join(testDir, "nonexistent.txt");
const result = await readTool.execute("test-call-2", { path: testFile }); await expect(readTool.execute("test-call-2", { path: testFile })).rejects.toThrow(/ENOENT|not found/i);
expect(getTextOutput(result)).toContain("Error");
expect(getTextOutput(result)).toContain("File not found");
}); });
it("should truncate files exceeding line limit", async () => { it("should truncate files exceeding line limit", async () => {
@ -139,11 +136,9 @@ describe("Coding Agent Tools", () => {
const testFile = join(testDir, "short.txt"); const testFile = join(testDir, "short.txt");
writeFileSync(testFile, "Line 1\nLine 2\nLine 3"); writeFileSync(testFile, "Line 1\nLine 2\nLine 3");
const result = await readTool.execute("test-call-8", { path: testFile, offset: 100 }); await expect(readTool.execute("test-call-8", { path: testFile, offset: 100 })).rejects.toThrow(
const output = getTextOutput(result); /Offset 100 is beyond end of file \(3 lines total\)/,
);
expect(output).toContain("Error: Offset 100 is beyond end of file");
expect(output).toContain("3 lines total");
}); });
it("should show both truncation notices when applicable", async () => { it("should show both truncation notices when applicable", async () => {
@ -206,13 +201,13 @@ describe("Coding Agent Tools", () => {
const originalContent = "Hello, world!"; const originalContent = "Hello, world!";
writeFileSync(testFile, originalContent); writeFileSync(testFile, originalContent);
const result = await editTool.execute("test-call-6", { await expect(
path: testFile, editTool.execute("test-call-6", {
oldText: "nonexistent", path: testFile,
newText: "testing", oldText: "nonexistent",
}); newText: "testing",
}),
expect(getTextOutput(result)).toContain("Could not find the exact text"); ).rejects.toThrow(/Could not find the exact text/);
}); });
it("should fail if text appears multiple times", async () => { it("should fail if text appears multiple times", async () => {
@ -220,13 +215,13 @@ describe("Coding Agent Tools", () => {
const originalContent = "foo foo foo"; const originalContent = "foo foo foo";
writeFileSync(testFile, originalContent); writeFileSync(testFile, originalContent);
const result = await editTool.execute("test-call-7", { await expect(
path: testFile, editTool.execute("test-call-7", {
oldText: "foo", path: testFile,
newText: "bar", oldText: "foo",
}); newText: "bar",
}),
expect(getTextOutput(result)).toContain("Found 3 occurrences"); ).rejects.toThrow(/Found 3 occurrences/);
}); });
}); });
@ -239,16 +234,16 @@ describe("Coding Agent Tools", () => {
}); });
it("should handle command errors", async () => { it("should handle command errors", async () => {
const result = await bashTool.execute("test-call-9", { command: "exit 1" }); await expect(bashTool.execute("test-call-9", { command: "exit 1" })).rejects.toThrow(
/(Command failed|code 1)/,
expect(getTextOutput(result)).toContain("Command failed"); );
}); });
it("should respect timeout", async () => { it("should respect timeout", async () => {
const result = await bashTool.execute("test-call-10", { command: "sleep 35" }); await expect(bashTool.execute("test-call-10", { command: "sleep 5", timeout: 1 })).rejects.toThrow(
/timed out/i,
expect(getTextOutput(result)).toContain("Command failed"); );
}, 35000); });
}); });
describe("grep tool", () => { describe("grep tool", () => {

View file

@ -48,6 +48,7 @@ export class Markdown implements Component {
private paddingY: number; // Top/bottom padding private paddingY: number; // Top/bottom padding
private defaultTextStyle?: DefaultTextStyle; private defaultTextStyle?: DefaultTextStyle;
private theme: MarkdownTheme; private theme: MarkdownTheme;
private defaultStylePrefix?: string;
// Cache for rendered output // Cache for rendered output
private cachedText?: string; private cachedText?: string;
@ -193,6 +194,40 @@ export class Markdown implements Component {
return styled; return styled;
} }
private getDefaultStylePrefix(): string {
if (!this.defaultTextStyle) {
return "";
}
if (this.defaultStylePrefix !== undefined) {
return this.defaultStylePrefix;
}
const sentinel = "\u0000";
let styled = sentinel;
if (this.defaultTextStyle.color) {
styled = this.defaultTextStyle.color(styled);
}
if (this.defaultTextStyle.bold) {
styled = this.theme.bold(styled);
}
if (this.defaultTextStyle.italic) {
styled = this.theme.italic(styled);
}
if (this.defaultTextStyle.strikethrough) {
styled = this.theme.strikethrough(styled);
}
if (this.defaultTextStyle.underline) {
styled = this.theme.underline(styled);
}
const sentinelIndex = styled.indexOf(sentinel);
this.defaultStylePrefix = sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : "";
return this.defaultStylePrefix;
}
private renderToken(token: Token, width: number, nextTokenType?: string): string[] { private renderToken(token: Token, width: number, nextTokenType?: string): string[] {
const lines: string[] = []; const lines: string[] = [];
@ -302,20 +337,20 @@ export class Markdown implements Component {
case "strong": { case "strong": {
// Apply bold, then reapply default style after // Apply bold, then reapply default style after
const boldContent = this.renderInlineTokens(token.tokens || []); const boldContent = this.renderInlineTokens(token.tokens || []);
result += this.theme.bold(boldContent) + this.applyDefaultStyle(""); result += this.theme.bold(boldContent) + this.getDefaultStylePrefix();
break; break;
} }
case "em": { case "em": {
// Apply italic, then reapply default style after // Apply italic, then reapply default style after
const italicContent = this.renderInlineTokens(token.tokens || []); const italicContent = this.renderInlineTokens(token.tokens || []);
result += this.theme.italic(italicContent) + this.applyDefaultStyle(""); result += this.theme.italic(italicContent) + this.getDefaultStylePrefix();
break; break;
} }
case "codespan": case "codespan":
// Apply code styling without backticks // Apply code styling without backticks
result += this.theme.code(token.text) + this.applyDefaultStyle(""); result += this.theme.code(token.text) + this.getDefaultStylePrefix();
break; break;
case "link": { case "link": {
@ -323,12 +358,12 @@ export class Markdown implements Component {
// If link text matches href, only show the link once // If link text matches href, only show the link once
// Compare raw text (token.text) not styled text (linkText) since linkText has ANSI codes // Compare raw text (token.text) not styled text (linkText) since linkText has ANSI codes
if (token.text === token.href) { if (token.text === token.href) {
result += this.theme.link(this.theme.underline(linkText)) + this.applyDefaultStyle(""); result += this.theme.link(this.theme.underline(linkText)) + this.getDefaultStylePrefix();
} else { } else {
result += result +=
this.theme.link(this.theme.underline(linkText)) + this.theme.link(this.theme.underline(linkText)) +
this.theme.linkUrl(` (${token.href})`) + this.theme.linkUrl(` (${token.href})`) +
this.applyDefaultStyle(""); this.getDefaultStylePrefix();
} }
break; break;
} }
@ -339,7 +374,7 @@ export class Markdown implements Component {
case "del": { case "del": {
const delContent = this.renderInlineTokens(token.tokens || []); const delContent = this.renderInlineTokens(token.tokens || []);
result += this.theme.strikethrough(delContent) + this.applyDefaultStyle(""); result += this.theme.strikethrough(delContent) + this.getDefaultStylePrefix();
break; break;
} }

View file

@ -179,7 +179,7 @@ function wrapSingleLine(line: string, width: number): string[] {
if (totalNeeded > width && currentVisibleLength > 0) { if (totalNeeded > width && currentVisibleLength > 0) {
// Wrap to next line - don't carry trailing whitespace // Wrap to next line - don't carry trailing whitespace
wrapped.push(currentLine); wrapped.push(currentLine.trimEnd());
if (isWhitespace) { if (isWhitespace) {
// Don't start new line with whitespace // Don't start new line with whitespace
currentLine = tracker.getActiveCodes(); currentLine = tracker.getActiveCodes();

View file

@ -1,9 +1,12 @@
import assert from "node:assert"; import assert from "node:assert";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import chalk from "chalk"; import { Chalk } from "chalk";
import { Markdown } from "../src/components/markdown.js"; import { Markdown } from "../src/components/markdown.js";
import { defaultMarkdownTheme } from "./test-themes.js"; import { defaultMarkdownTheme } from "./test-themes.js";
// Force full color in CI so ANSI assertions are deterministic
const chalk = new Chalk({ level: 3 });
describe("Markdown component", () => { describe("Markdown component", () => {
describe("Nested lists", () => { describe("Nested lists", () => {
it("should render simple nested list", () => { it("should render simple nested list", () => {
@ -218,9 +221,9 @@ describe("Markdown component", () => {
assert.ok(joinedOutput.includes("\x1b[90m"), "Should have gray color code"); assert.ok(joinedOutput.includes("\x1b[90m"), "Should have gray color code");
assert.ok(joinedOutput.includes("\x1b[3m"), "Should have italic code"); assert.ok(joinedOutput.includes("\x1b[3m"), "Should have italic code");
// Verify that after the inline code (cyan text), we reapply gray italic // Verify that inline code is styled (theme uses yellow)
const hasCyan = joinedOutput.includes("\x1b[36m"); // cyan const hasCodeColor = joinedOutput.includes("\x1b[33m");
assert.ok(hasCyan, "Should have cyan for inline code"); assert.ok(hasCodeColor, "Should style inline code");
}); });
it("should preserve gray italic styling after bold text", () => { it("should preserve gray italic styling after bold text", () => {

View file

@ -2,9 +2,11 @@
* Default themes for TUI tests using chalk * Default themes for TUI tests using chalk
*/ */
import chalk from "chalk"; import { Chalk } from "chalk";
import type { EditorTheme, MarkdownTheme, SelectListTheme } from "../src/index.js"; import type { EditorTheme, MarkdownTheme, SelectListTheme } from "../src/index.js";
const chalk = new Chalk({ level: 3 });
export const defaultSelectListTheme: SelectListTheme = { export const defaultSelectListTheme: SelectListTheme = {
selectedPrefix: (text: string) => chalk.blue(text), selectedPrefix: (text: string) => chalk.blue(text),
selectedText: (text: string) => chalk.bold(text), selectedText: (text: string) => chalk.bold(text),

View file

@ -1,9 +1,12 @@
import assert from "node:assert"; import assert from "node:assert";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import chalk from "chalk"; import { Chalk } from "chalk";
import { TruncatedText } from "../src/components/truncated-text.js"; import { TruncatedText } from "../src/components/truncated-text.js";
import { visibleWidth } from "../src/utils.js"; import { visibleWidth } from "../src/utils.js";
// Force full color in CI so ANSI assertions are deterministic
const chalk = new Chalk({ level: 3 });
describe("TruncatedText component", () => { describe("TruncatedText component", () => {
it("pads output lines to exactly match width", () => { it("pads output lines to exactly match width", () => {
const text = new TruncatedText("Hello world", 1, 0); const text = new TruncatedText("Hello world", 1, 0);

View file

@ -4,6 +4,12 @@
"module": "ES2022", "module": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler", "moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"@mariozechner/pi-ai": ["../../ai/dist/index.d.ts"],
"@mariozechner/pi-tui": ["../../tui/dist/index.d.ts"],
"@mariozechner/pi-web-ui": ["../dist/index.d.ts"]
},
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,
@ -11,5 +17,6 @@
"experimentalDecorators": true, "experimentalDecorators": true,
"useDefineForClassFields": false "useDefineForClassFields": false
}, },
"include": ["src/**/*"] "include": ["src/**/*"],
"exclude": ["../src"]
} }

View file

@ -3,6 +3,28 @@
"target": "ES2022", "target": "ES2022",
"module": "Node16", "module": "Node16",
"lib": ["ES2022"], "lib": ["ES2022"],
"paths": {
"*": ["./*"],
"@mariozechner/pi-ai": ["./packages/ai/src/index.ts"],
"@mariozechner/pi-ai/*": ["./packages/ai/src/*"],
"@mariozechner/pi-ai/dist/*": ["./packages/ai/src/*"],
"@mariozechner/pi-agent-core": ["./packages/agent/src/index.ts"],
"@mariozechner/pi-agent-core/*": ["./packages/agent/src/*"],
"@mariozechner/pi-coding-agent": ["./packages/coding-agent/src/index.ts"],
"@mariozechner/pi-coding-agent/*": ["./packages/coding-agent/src/*"],
"@mariozechner/pi-mom": ["./packages/mom/src/index.ts"],
"@mariozechner/pi-mom/*": ["./packages/mom/src/*"],
"@mariozechner/pi": ["./packages/pods/src/index.ts"],
"@mariozechner/pi/*": ["./packages/pods/src/*"],
"@mariozechner/pi-proxy": ["./packages/proxy/src/index.ts"],
"@mariozechner/pi-proxy/*": ["./packages/proxy/src/*"],
"@mariozechner/pi-tui": ["./packages/tui/src/index.ts"],
"@mariozechner/pi-tui/*": ["./packages/tui/src/*"],
"@mariozechner/pi-web-ui": ["./packages/web-ui/src/index.ts"],
"@mariozechner/pi-web-ui/*": ["./packages/web-ui/src/*"],
"@mariozechner/pi-agent-old": ["./packages/agent-old/src/index.ts"],
"@mariozechner/pi-agent-old/*": ["./packages/agent-old/src/*"]
},
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,

View file

@ -3,14 +3,26 @@
"compilerOptions": { "compilerOptions": {
"noEmit": true, "noEmit": true,
"paths": { "paths": {
"@mariozechner/pi-tui": ["./packages/tui/src/index.ts"], "*": ["./*"],
"@mariozechner/pi-ai": ["./packages/ai/src/index.ts"], "@mariozechner/pi-ai": ["./packages/ai/src/index.ts"],
"@mariozechner/pi-web-ui": ["./packages/web-ui/src/index.ts"], "@mariozechner/pi-ai/*": ["./packages/ai/src/*"],
"@mariozechner/pi-agent": ["./packages/agent/src/index.ts"], "@mariozechner/pi-ai/dist/*": ["./packages/ai/src/*"],
"@mariozechner/pi-agent-old": ["./packages/agent-old/src/index.ts"], "@mariozechner/pi-agent-core": ["./packages/agent/src/index.ts"],
"@mariozechner/coding-agent": ["./packages/coding-agent/src/index.ts"], "@mariozechner/pi-agent-core/*": ["./packages/agent/src/*"],
"@mariozechner/pi-coding-agent": ["./packages/coding-agent/src/index.ts"],
"@mariozechner/pi-coding-agent/*": ["./packages/coding-agent/src/*"],
"@mariozechner/pi-mom": ["./packages/mom/src/index.ts"],
"@mariozechner/pi-mom/*": ["./packages/mom/src/*"],
"@mariozechner/pi": ["./packages/pods/src/index.ts"], "@mariozechner/pi": ["./packages/pods/src/index.ts"],
"@mariozechner/pi-mom": ["./packages/mom/src/main.ts"] "@mariozechner/pi/*": ["./packages/pods/src/*"],
"@mariozechner/pi-proxy": ["./packages/proxy/src/index.ts"],
"@mariozechner/pi-proxy/*": ["./packages/proxy/src/*"],
"@mariozechner/pi-tui": ["./packages/tui/src/index.ts"],
"@mariozechner/pi-tui/*": ["./packages/tui/src/*"],
"@mariozechner/pi-web-ui": ["./packages/web-ui/src/index.ts"],
"@mariozechner/pi-web-ui/*": ["./packages/web-ui/src/*"],
"@mariozechner/pi-agent-old": ["./packages/agent-old/src/index.ts"],
"@mariozechner/pi-agent-old/*": ["./packages/agent-old/src/*"]
} }
}, },
"include": ["packages/*/src/**/*", "packages/*/test/**/*"] "include": ["packages/*/src/**/*", "packages/*/test/**/*"]