From c43f1d307c672c137e17dfd643b0a556a5dbe72c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Dec 2025 12:12:17 +0000 Subject: [PATCH] Add CI workflow and fix workspace tests --- .github/workflows/ci.yml | 59 ++++++++++++++++++++++++ packages/coding-agent/test/rpc.test.ts | 5 +- packages/coding-agent/test/tools.test.ts | 55 ++++++++++------------ packages/tui/src/components/markdown.ts | 47 ++++++++++++++++--- packages/tui/src/utils.ts | 2 +- packages/tui/test/markdown.test.ts | 11 +++-- packages/tui/test/test-themes.ts | 4 +- packages/tui/test/truncated-text.test.ts | 5 +- packages/web-ui/example/tsconfig.json | 9 +++- tsconfig.base.json | 22 +++++++++ tsconfig.json | 24 +++++++--- 11 files changed, 192 insertions(+), 51 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..9ece66d4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 }} diff --git a/packages/coding-agent/test/rpc.test.ts b/packages/coding-agent/test/rpc.test.ts index f70dd868..de45a81b 100644 --- a/packages/coding-agent/test/rpc.test.ts +++ b/packages/coding-agent/test/rpc.test.ts @@ -7,13 +7,16 @@ import { fileURLToPath } from "node:url"; import type { AgentEvent } from "@mariozechner/pi-agent-core"; 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)); /** * RPC mode tests. * Regression test for issue #83: https://github.com/badlogic/pi-mono/issues/83 */ -describe("RPC mode", () => { +maybeDescribe("RPC mode", () => { let agent: ChildProcess; let sessionDir: string; diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 521f599d..8331315b 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -50,10 +50,7 @@ describe("Coding Agent Tools", () => { it("should handle non-existent files", async () => { const testFile = join(testDir, "nonexistent.txt"); - const result = await readTool.execute("test-call-2", { path: testFile }); - - expect(getTextOutput(result)).toContain("Error"); - expect(getTextOutput(result)).toContain("File not found"); + await expect(readTool.execute("test-call-2", { path: testFile })).rejects.toThrow(/ENOENT|not found/i); }); it("should truncate files exceeding line limit", async () => { @@ -139,11 +136,9 @@ describe("Coding Agent Tools", () => { const testFile = join(testDir, "short.txt"); writeFileSync(testFile, "Line 1\nLine 2\nLine 3"); - const result = await readTool.execute("test-call-8", { path: testFile, offset: 100 }); - const output = getTextOutput(result); - - expect(output).toContain("Error: Offset 100 is beyond end of file"); - expect(output).toContain("3 lines total"); + await expect(readTool.execute("test-call-8", { path: testFile, offset: 100 })).rejects.toThrow( + /Offset 100 is beyond end of file \(3 lines total\)/, + ); }); it("should show both truncation notices when applicable", async () => { @@ -206,13 +201,13 @@ describe("Coding Agent Tools", () => { const originalContent = "Hello, world!"; writeFileSync(testFile, originalContent); - const result = await editTool.execute("test-call-6", { - path: testFile, - oldText: "nonexistent", - newText: "testing", - }); - - expect(getTextOutput(result)).toContain("Could not find the exact text"); + await expect( + editTool.execute("test-call-6", { + path: testFile, + oldText: "nonexistent", + newText: "testing", + }), + ).rejects.toThrow(/Could not find the exact text/); }); it("should fail if text appears multiple times", async () => { @@ -220,13 +215,13 @@ describe("Coding Agent Tools", () => { const originalContent = "foo foo foo"; writeFileSync(testFile, originalContent); - const result = await editTool.execute("test-call-7", { - path: testFile, - oldText: "foo", - newText: "bar", - }); - - expect(getTextOutput(result)).toContain("Found 3 occurrences"); + await expect( + editTool.execute("test-call-7", { + path: testFile, + oldText: "foo", + newText: "bar", + }), + ).rejects.toThrow(/Found 3 occurrences/); }); }); @@ -239,16 +234,16 @@ describe("Coding Agent Tools", () => { }); it("should handle command errors", async () => { - const result = await bashTool.execute("test-call-9", { command: "exit 1" }); - - expect(getTextOutput(result)).toContain("Command failed"); + await expect(bashTool.execute("test-call-9", { command: "exit 1" })).rejects.toThrow( + /(Command failed|code 1)/, + ); }); it("should respect timeout", async () => { - const result = await bashTool.execute("test-call-10", { command: "sleep 35" }); - - expect(getTextOutput(result)).toContain("Command failed"); - }, 35000); + await expect(bashTool.execute("test-call-10", { command: "sleep 5", timeout: 1 })).rejects.toThrow( + /timed out/i, + ); + }); }); describe("grep tool", () => { diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index fa59d48c..f2697bc1 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -48,6 +48,7 @@ export class Markdown implements Component { private paddingY: number; // Top/bottom padding private defaultTextStyle?: DefaultTextStyle; private theme: MarkdownTheme; + private defaultStylePrefix?: string; // Cache for rendered output private cachedText?: string; @@ -193,6 +194,40 @@ export class Markdown implements Component { 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[] { const lines: string[] = []; @@ -302,20 +337,20 @@ export class Markdown implements Component { case "strong": { // Apply bold, then reapply default style after const boldContent = this.renderInlineTokens(token.tokens || []); - result += this.theme.bold(boldContent) + this.applyDefaultStyle(""); + result += this.theme.bold(boldContent) + this.getDefaultStylePrefix(); break; } case "em": { // Apply italic, then reapply default style after const italicContent = this.renderInlineTokens(token.tokens || []); - result += this.theme.italic(italicContent) + this.applyDefaultStyle(""); + result += this.theme.italic(italicContent) + this.getDefaultStylePrefix(); break; } case "codespan": // Apply code styling without backticks - result += this.theme.code(token.text) + this.applyDefaultStyle(""); + result += this.theme.code(token.text) + this.getDefaultStylePrefix(); break; case "link": { @@ -323,12 +358,12 @@ export class Markdown implements Component { // If link text matches href, only show the link once // Compare raw text (token.text) not styled text (linkText) since linkText has ANSI codes 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 { result += this.theme.link(this.theme.underline(linkText)) + this.theme.linkUrl(` (${token.href})`) + - this.applyDefaultStyle(""); + this.getDefaultStylePrefix(); } break; } @@ -339,7 +374,7 @@ export class Markdown implements Component { case "del": { const delContent = this.renderInlineTokens(token.tokens || []); - result += this.theme.strikethrough(delContent) + this.applyDefaultStyle(""); + result += this.theme.strikethrough(delContent) + this.getDefaultStylePrefix(); break; } diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index c73d51a3..8dbbb540 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -179,7 +179,7 @@ function wrapSingleLine(line: string, width: number): string[] { if (totalNeeded > width && currentVisibleLength > 0) { // Wrap to next line - don't carry trailing whitespace - wrapped.push(currentLine); + wrapped.push(currentLine.trimEnd()); if (isWhitespace) { // Don't start new line with whitespace currentLine = tracker.getActiveCodes(); diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 56144dad..3a74c10e 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -1,9 +1,12 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import chalk from "chalk"; +import { Chalk } from "chalk"; import { Markdown } from "../src/components/markdown.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("Nested lists", () => { 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[3m"), "Should have italic code"); - // Verify that after the inline code (cyan text), we reapply gray italic - const hasCyan = joinedOutput.includes("\x1b[36m"); // cyan - assert.ok(hasCyan, "Should have cyan for inline code"); + // Verify that inline code is styled (theme uses yellow) + const hasCodeColor = joinedOutput.includes("\x1b[33m"); + assert.ok(hasCodeColor, "Should style inline code"); }); it("should preserve gray italic styling after bold text", () => { diff --git a/packages/tui/test/test-themes.ts b/packages/tui/test/test-themes.ts index 9ef55042..ff68b2bb 100644 --- a/packages/tui/test/test-themes.ts +++ b/packages/tui/test/test-themes.ts @@ -2,9 +2,11 @@ * Default themes for TUI tests using chalk */ -import chalk from "chalk"; +import { Chalk } from "chalk"; import type { EditorTheme, MarkdownTheme, SelectListTheme } from "../src/index.js"; +const chalk = new Chalk({ level: 3 }); + export const defaultSelectListTheme: SelectListTheme = { selectedPrefix: (text: string) => chalk.blue(text), selectedText: (text: string) => chalk.bold(text), diff --git a/packages/tui/test/truncated-text.test.ts b/packages/tui/test/truncated-text.test.ts index ef24f368..a3772a91 100644 --- a/packages/tui/test/truncated-text.test.ts +++ b/packages/tui/test/truncated-text.test.ts @@ -1,9 +1,12 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import chalk from "chalk"; +import { Chalk } from "chalk"; import { TruncatedText } from "../src/components/truncated-text.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", () => { it("pads output lines to exactly match width", () => { const text = new TruncatedText("Hello world", 1, 0); diff --git a/packages/web-ui/example/tsconfig.json b/packages/web-ui/example/tsconfig.json index 8321c62b..51b2cdd9 100644 --- a/packages/web-ui/example/tsconfig.json +++ b/packages/web-ui/example/tsconfig.json @@ -4,6 +4,12 @@ "module": "ES2022", "lib": ["ES2022", "DOM", "DOM.Iterable"], "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, "skipLibCheck": true, "esModuleInterop": true, @@ -11,5 +17,6 @@ "experimentalDecorators": true, "useDefineForClassFields": false }, - "include": ["src/**/*"] + "include": ["src/**/*"], + "exclude": ["../src"] } diff --git a/tsconfig.base.json b/tsconfig.base.json index bd9a33a4..36c027a6 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -3,6 +3,28 @@ "target": "ES2022", "module": "Node16", "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, "esModuleInterop": true, "skipLibCheck": true, diff --git a/tsconfig.json b/tsconfig.json index 2c833214..d5873ae2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,14 +3,26 @@ "compilerOptions": { "noEmit": true, "paths": { - "@mariozechner/pi-tui": ["./packages/tui/src/index.ts"], + "*": ["./*"], "@mariozechner/pi-ai": ["./packages/ai/src/index.ts"], - "@mariozechner/pi-web-ui": ["./packages/web-ui/src/index.ts"], - "@mariozechner/pi-agent": ["./packages/agent/src/index.ts"], - "@mariozechner/pi-agent-old": ["./packages/agent-old/src/index.ts"], - "@mariozechner/coding-agent": ["./packages/coding-agent/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-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/**/*"]