From e912caf98018a7ca9cb695a53b6757927a5385f9 Mon Sep 17 00:00:00 2001 From: Oxygen <1391083091@qq.com> Date: Fri, 5 Jun 2026 03:18:20 +0800 Subject: [PATCH 1/5] fix: correct npm package name, Node version check, and doc versions - install.sh: Update Node.js version check from >=18 to >=20 to match package.json engines field - Documentation.md: Update version string from 1.3.3 to 1.5.2 and npm update command to use scoped package @open-gitagent/gitagent - README.md: Update Node.js requirement from 18+ to 20+ in install instructions and FAQ, fix npm badge to use scoped package name - tsconfig.json: Exclude __tests__ directories from compilation Co-Authored-By: Claude Opus 4.8 --- Documentation.md | 4 ++-- README.md | 6 +++--- install.sh | 4 ++-- package-lock.json | 1 + tsconfig.json | 2 +- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/Documentation.md b/Documentation.md index d57eff4..617f7cf 100644 --- a/Documentation.md +++ b/Documentation.md @@ -1,7 +1,7 @@ # GitAgent Documentation > **GitAgent** — A universal git-native multimodal always-learning AI Agent -> Version 1.3.3 | MIT License | [github.com/open-gitagent/gitagent](https://github.com/open-gitagent/gitagent) +> Version 1.5.2 | MIT License | [github.com/open-gitagent/gitagent](https://github.com/open-gitagent/gitagent) --- @@ -83,7 +83,7 @@ The installer offers four options: curl -fsSL https://raw.githubusercontent.com/open-gitagent/gitagent/main/install.sh | bash # Or manually -npm update -g gitagent +npm update -g @open-gitagent/gitagent ``` --- diff --git a/README.md b/README.md index c684c6d..ea8baf9 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

- npm version + npm version node version license typescript @@ -56,7 +56,7 @@ This will: - Walk you through API key setup (Quick or Advanced mode) - Launch the voice UI in your browser at `http://localhost:3333` -> **Requirements:** Node.js 18+, npm, git +> **Requirements:** Node.js 20+, npm, git ### Or install manually: @@ -752,7 +752,7 @@ Your agent lives in a git repository with structured files: ### Installation & Setup **What are the requirements?** -Node.js 18+ (or 20+ recommended), npm, and git. Install globally with `npm install -g @open-gitagent/gitagent`. +Node.js 20+, npm, and git. Install globally with `npm install -g @open-gitagent/gitagent`. **How do I set up API keys?** Run the installer for guided setup: diff --git a/install.sh b/install.sh index 1325f44..472e700 100755 --- a/install.sh +++ b/install.sh @@ -91,8 +91,8 @@ check_cmd npm check_cmd git NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1) -if [ "$NODE_VERSION" -lt 18 ]; then - echo -e " ${RED}✗ Node.js 18+ required (found $(node -v))${NC}" +if [ "$NODE_VERSION" -lt 20 ]; then + echo -e " ${RED}✗ Node.js 20+ required (found $(node -v))${NC}" exit 1 fi diff --git a/package-lock.json b/package-lock.json index 1e81523..dd28020 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3257,6 +3257,7 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8" } diff --git a/tsconfig.json b/tsconfig.json index 64429fa..be84a50 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,5 +13,5 @@ "resolveJsonModule": true }, "include": ["src"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "**/__tests__"] } From d0295dad364dc79174a8c6d33f89641420fb463b Mon Sep 17 00:00:00 2001 From: Oxygen <1391083091@qq.com> Date: Fri, 5 Jun 2026 03:18:25 +0800 Subject: [PATCH 2/5] test: implement unit tests for memory and telemetry modules - src/tools/__tests__/memory.test.ts: Replace 3 it.todo() stubs with 7 real tests covering load, save (with git commits), default message, content validation, and abort signal handling - src/__tests__/telemetry.test.ts: Replace 2 it.todo() stubs with 4 real tests covering initTelemetry behavior without endpoint, with _testProvider, idempotency, and shutdown state reset Co-Authored-By: Claude Opus 4.8 --- src/__tests__/telemetry.test.ts | 134 ++++++++++++++++++- src/tools/__tests__/memory.test.ts | 198 ++++++++++++++++++++++++++++- 2 files changed, 325 insertions(+), 7 deletions(-) diff --git a/src/__tests__/telemetry.test.ts b/src/__tests__/telemetry.test.ts index 232554d..7aeff1f 100644 --- a/src/__tests__/telemetry.test.ts +++ b/src/__tests__/telemetry.test.ts @@ -1,6 +1,134 @@ -import { describe, it } from "node:test"; +/** + * Tests for the telemetry module (src/telemetry.ts). + * + * These tests verify that initTelemetry correctly gates on the OTLP + * endpoint environment variable: it MUST be a no-op when the endpoint + * is not configured, and it MUST successfully create an SDK instance + * when an endpoint (or test provider) is provided. + */ +import { describe, it, before, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { trace } from "@opentelemetry/api"; +import { + NodeTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from "@opentelemetry/sdk-trace-node"; + +let initTelemetry: typeof import("../telemetry.ts").initTelemetry; +let shutdownTelemetry: typeof import("../telemetry.ts").shutdownTelemetry; +let isTelemetryEnabled: typeof import("../telemetry.ts").isTelemetryEnabled; + +before(async () => { + const mod = await import("../telemetry.ts"); + initTelemetry = mod.initTelemetry; + shutdownTelemetry = mod.shutdownTelemetry; + isTelemetryEnabled = mod.isTelemetryEnabled; +}); + +afterEach(async () => { + // Always clean up telemetry after each test to avoid cross-test + // contamination from global state. + await shutdownTelemetry(); + try { + trace.disable(); + } catch { + /* ignore */ + } +}); describe("telemetry", () => { - it.todo("initTelemetry is a no-op when OTEL_EXPORTER_OTLP_ENDPOINT is not set"); - it.todo("initTelemetry creates an SDK instance when endpoint is configured"); + // ── Helpers ────────────────────────────────────────────────────── + + function makeTestProvider() { + const exporter = new InMemorySpanExporter(); + const provider = new NodeTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(exporter)], + }); + return { exporter, provider }; + } + + // ── initTelemetry no-op ────────────────────────────────────────── + + it("initTelemetry without OTLP endpoint does not throw and leaves module in a consistent state", async () => { + // Ensure the env var is not set for this test + const saved = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + + try { + // Calling initTelemetry with no options does not throw — the + // module always wraps its body in try/catch so failures are + // logged, not thrown. + await assert.doesNotReject( + () => initTelemetry({}), + "initTelemetry must never throw, even without an endpoint", + ); + } finally { + if (saved !== undefined) { + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = saved; + } + } + }); + + // ── initTelemetry with endpoint ────────────────────────────────── + + it("initTelemetry creates an SDK instance when endpoint is configured", async () => { + // Set a (bogus) OTLP endpoint so the init path proceeds past the + // no-op guard. Because we do not have a real collector, we also + // provide a _testProvider so the test is deterministic. + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4318"; + const { exporter, provider } = makeTestProvider(); + + try { + await initTelemetry({ + serviceName: "test-svc", + _testProvider: provider, + }); + + assert.equal( + isTelemetryEnabled(), + true, + "telemetry must be enabled after initTelemetry with _testProvider", + ); + + // Verify the provider was actually registered: create a span + // and confirm it flows through the InMemorySpanExporter. + const tracer = trace.getTracer("test"); + const span = tracer.startSpan("test-span"); + span.end(); + + // Force flush by shutting down (which is handled by afterEach) + const spans = exporter.getFinishedSpans(); + assert.equal(spans.length, 1, "span should be exported"); + assert.equal(spans[0].name, "test-span"); + } finally { + delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + } + }); + + // ── Idempotency ────────────────────────────────────────────────── + + it("initTelemetry is idempotent", async () => { + const { provider: provider1 } = makeTestProvider(); + const { provider: provider2 } = makeTestProvider(); + + await initTelemetry({ _testProvider: provider1 }); + assert.equal(isTelemetryEnabled(), true); + + // Second call should be a no-op — _initialized is already true + await initTelemetry({ _testProvider: provider2 }); + assert.equal(isTelemetryEnabled(), true); + }); + + // ── shutdownTelemetry ──────────────────────────────────────────── + + it("shutdownTelemetry resets the initialized state", async () => { + const { provider } = makeTestProvider(); + + await initTelemetry({ _testProvider: provider }); + assert.equal(isTelemetryEnabled(), true); + + await shutdownTelemetry(); + assert.equal(isTelemetryEnabled(), false); + }); }); diff --git a/src/tools/__tests__/memory.test.ts b/src/tools/__tests__/memory.test.ts index 1a2269e..9c7cd5f 100644 --- a/src/tools/__tests__/memory.test.ts +++ b/src/tools/__tests__/memory.test.ts @@ -1,7 +1,197 @@ -import { describe, it } from "node:test"; +/** + * Tests for the memory tool (src/tools/memory.ts). + * + * The memory tool provides git-backed persistent memory with load/save + * operations. Each save creates a git commit, giving full history of + * what the agent has remembered. + */ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile, mkdir } from "fs/promises"; +import { join } from "path"; +import { tmpdir } from "os"; +import { execSync } from "child_process"; + +let createMemoryTool: typeof import("../../../dist/tools/memory.js").createMemoryTool; + +before(async () => { + const mod = await import("../../../dist/tools/memory.js"); + createMemoryTool = mod.createMemoryTool; +}); describe("memory tool", () => { - it.todo("load returns stored memory content"); - it.todo("save writes content and commits to git"); - it.todo("save requires content and message"); + /** Create a temporary directory with git init and return the path. */ + async function setupRepo(): Promise { + const dir = await mkdtemp(join(tmpdir(), "gitagent-memory-test-")); + execSync("git init -q", { cwd: dir }); + // Configure git user for commits + execSync('git config user.email "test@gitagent.test"', { cwd: dir }); + execSync('git config user.name "Test Agent"', { cwd: dir }); + return dir; + } + + /** Clean up a temp directory. */ + async function cleanup(dir: string): Promise { + await rm(dir, { recursive: true, force: true }).catch(() => {}); + } + + // ── load ───────────────────────────────────────────────────────── + + describe("load", () => { + it("returns stored memory content", async () => { + const dir = await setupRepo(); + try { + const tool = createMemoryTool(dir); + + // First, save some memory content + await tool.execute("call-1", { + action: "save", + content: "# Memory\n\n- Remember to buy milk\n- Project uses TypeScript", + message: "Initial memory", + }); + + // Now load it + const result = await tool.execute("call-2", { action: "load" }); + + assert.ok(result.content); + assert.equal(result.content.length, 1); + assert.ok(result.content[0].text.includes("Remember to buy milk")); + assert.ok(result.content[0].text.includes("Project uses TypeScript")); + } finally { + await cleanup(dir); + } + }); + + it("returns 'No memories yet.' when memory file is empty or missing", async () => { + const dir = await setupRepo(); + try { + const tool = createMemoryTool(dir); + + // Load from a repo with no memory file + const result = await tool.execute("call-1", { action: "load" }); + + assert.equal(result.content[0].text, "No memories yet."); + } finally { + await cleanup(dir); + } + }); + + it("returns 'No memories yet.' when memory file has only heading", async () => { + const dir = await setupRepo(); + try { + // Write the default heading-only memory file + await mkdir(join(dir, "memory"), { recursive: true }); + await writeFile(join(dir, "memory", "MEMORY.md"), "# Memory", "utf-8"); + + const tool = createMemoryTool(dir); + const result = await tool.execute("call-1", { action: "load" }); + + assert.equal(result.content[0].text, "No memories yet."); + } finally { + await cleanup(dir); + } + }); + }); + + // ── save ───────────────────────────────────────────────────────── + + describe("save", () => { + it("writes content and commits to git", async () => { + const dir = await setupRepo(); + try { + const tool = createMemoryTool(dir); + + const result = await tool.execute("call-1", { + action: "save", + content: "# Memory\n\nSaved entry one.", + message: "First save", + }); + + assert.equal(result.content.length, 1); + assert.ok( + result.content[0].text.includes("Memory saved and committed"), + ); + assert.ok(result.content[0].text.includes("First save")); + + // Verify the file was written + const { readFile } = await import("fs/promises"); + const fileContent = await readFile( + join(dir, "memory", "MEMORY.md"), + "utf-8", + ); + assert.ok(fileContent.includes("Saved entry one")); + + // Verify the git commit exists + const log = execSync("git log --oneline", { + cwd: dir, + encoding: "utf-8", + }); + assert.ok(log.includes("First save"), `git log should contain commit: ${log}`); + } finally { + await cleanup(dir); + } + }); + + it("uses default commit message when message is omitted", async () => { + const dir = await setupRepo(); + try { + const tool = createMemoryTool(dir); + + await tool.execute("call-1", { + action: "save", + content: "Memory without explicit message.", + }); + + const log = execSync("git log --oneline", { + cwd: dir, + encoding: "utf-8", + }); + assert.ok( + log.includes("Update memory"), + `commit should default to "Update memory": ${log}`, + ); + } finally { + await cleanup(dir); + } + }); + + it("requires content for save action", async () => { + const dir = await setupRepo(); + try { + const tool = createMemoryTool(dir); + + await assert.rejects( + () => + tool.execute("call-1", { + action: "save", + // content intentionally omitted + }), + /content is required for save action/, + ); + } finally { + await cleanup(dir); + } + }); + }); + + // ── abort signal ───────────────────────────────────────────────── + + describe("abort signal", () => { + it("throws when signal is already aborted", async () => { + const dir = await setupRepo(); + try { + const tool = createMemoryTool(dir); + const controller = new AbortController(); + controller.abort(); + + await assert.rejects( + () => + tool.execute("call-1", { action: "load" }, controller.signal), + /Operation aborted/, + ); + } finally { + await cleanup(dir); + } + }); + }); }); From 4486c2113c57cfa3f453865f0217d2f0593d7fac Mon Sep 17 00:00:00 2001 From: Oxygen <1391083091@qq.com> Date: Wed, 10 Jun 2026 23:25:29 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20import=20paths,=20forceFlush,=20git=20config=20scop?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - telemetry.test.ts: Move InMemorySpanExporter/SimpleSpanProcessor imports from @opentelemetry/sdk-trace-node to @opentelemetry/sdk-trace-base - telemetry.test.ts: Add await provider.forceFlush() before getFinishedSpans() to prevent span export race condition - memory.test.ts: Import from source (../memory.ts) instead of compiled output (../../../dist/tools/memory.js) so tests run without pre-build - memory.test.ts: Add --local flag to git config commands to scope explicitly --- src/__tests__/telemetry.test.ts | 8 ++++++-- src/tools/__tests__/memory.test.ts | 8 ++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/__tests__/telemetry.test.ts b/src/__tests__/telemetry.test.ts index 7aeff1f..d7cb0ea 100644 --- a/src/__tests__/telemetry.test.ts +++ b/src/__tests__/telemetry.test.ts @@ -11,9 +11,11 @@ import assert from "node:assert/strict"; import { trace } from "@opentelemetry/api"; import { NodeTracerProvider, +} from "@opentelemetry/sdk-trace-node"; +import { InMemorySpanExporter, SimpleSpanProcessor, -} from "@opentelemetry/sdk-trace-node"; +} from "@opentelemetry/sdk-trace-base"; let initTelemetry: typeof import("../telemetry.ts").initTelemetry; let shutdownTelemetry: typeof import("../telemetry.ts").shutdownTelemetry; @@ -97,7 +99,9 @@ describe("telemetry", () => { const span = tracer.startSpan("test-span"); span.end(); - // Force flush by shutting down (which is handled by afterEach) + // Force flush so the span lands in the in-memory exporter + // before we read it back. + await provider.forceFlush(); const spans = exporter.getFinishedSpans(); assert.equal(spans.length, 1, "span should be exported"); assert.equal(spans[0].name, "test-span"); diff --git a/src/tools/__tests__/memory.test.ts b/src/tools/__tests__/memory.test.ts index 9c7cd5f..c02f5e0 100644 --- a/src/tools/__tests__/memory.test.ts +++ b/src/tools/__tests__/memory.test.ts @@ -12,10 +12,10 @@ import { join } from "path"; import { tmpdir } from "os"; import { execSync } from "child_process"; -let createMemoryTool: typeof import("../../../dist/tools/memory.js").createMemoryTool; +let createMemoryTool: typeof import("../memory.ts").createMemoryTool; before(async () => { - const mod = await import("../../../dist/tools/memory.js"); + const mod = await import("../memory.ts"); createMemoryTool = mod.createMemoryTool; }); @@ -25,8 +25,8 @@ describe("memory tool", () => { const dir = await mkdtemp(join(tmpdir(), "gitagent-memory-test-")); execSync("git init -q", { cwd: dir }); // Configure git user for commits - execSync('git config user.email "test@gitagent.test"', { cwd: dir }); - execSync('git config user.name "Test Agent"', { cwd: dir }); + execSync('git config --local user.email "test@gitagent.test"', { cwd: dir }); + execSync('git config --local user.name "Test Agent"', { cwd: dir }); return dir; } From a5a7036d2ffd7a6f2253aee9516ac0e4b2f8c355 Mon Sep 17 00:00:00 2001 From: Oxygen <1391083091@qq.com> Date: Wed, 10 Jun 2026 23:42:47 +0800 Subject: [PATCH 4/5] fix: use 'KEY' in process.env pattern for env var restoration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous check (saved !== undefined) conflated "value is undefined" with "key was never set". Using the 'in' operator correctly detects whether the key existed before we deleted it, and restores (or deletes) the key accordingly. Also add a comment explaining that trace.disable() is not dead code — it unregisters the global tracer provider and installs a no-op proxy, which is essential for test isolation. --- src/__tests__/telemetry.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/__tests__/telemetry.test.ts b/src/__tests__/telemetry.test.ts index d7cb0ea..7e91f2a 100644 --- a/src/__tests__/telemetry.test.ts +++ b/src/__tests__/telemetry.test.ts @@ -32,6 +32,9 @@ afterEach(async () => { // Always clean up telemetry after each test to avoid cross-test // contamination from global state. await shutdownTelemetry(); + // trace.disable() unregisters the global tracer provider and + // installs a no-op ProxyTracerProvider, preventing stale spans + // from a previous test's provider leaking into the next test. try { trace.disable(); } catch { @@ -55,6 +58,7 @@ describe("telemetry", () => { it("initTelemetry without OTLP endpoint does not throw and leaves module in a consistent state", async () => { // Ensure the env var is not set for this test const saved = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + const wasSet = "OTEL_EXPORTER_OTLP_ENDPOINT" in process.env; delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; try { @@ -66,8 +70,10 @@ describe("telemetry", () => { "initTelemetry must never throw, even without an endpoint", ); } finally { - if (saved !== undefined) { + if (wasSet) { process.env.OTEL_EXPORTER_OTLP_ENDPOINT = saved; + } else { + delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; } } }); From c645a8a7eecd552fdf58d8ef154ecf14ae5e0d50 Mon Sep 17 00:00:00 2001 From: Oxygen <1391083091@qq.com> Date: Fri, 12 Jun 2026 18:49:24 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20import=20source,=20endpoint=20guard,=20test=20disco?= =?UTF-8?q?verability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - telemetry.test.ts: import InMemorySpanExporter/SimpleSpanProcessor from @opentelemetry/sdk-trace-node (listed dep) instead of sdk-trace-base (transitive only, would fail at runtime) - telemetry.ts: add early-return guard when no OTLP endpoint is configured and no _testProvider is given, so initTelemetry truly is a no-op - Move test files from src/__tests__/ to test/ so npm test glob finds them - Switch test runner to tsx (handles .js→.ts resolution that --experimental-strip-types cannot, required by memory.ts → shared.ts) - Revert tsconfig.json exclude change (tests no longer under src/) --- package.json | 2 +- src/__tests__/telemetry.test.ts | 144 ------------------- src/telemetry.ts | 5 + {src/tools/__tests__ => test}/memory.test.ts | 20 +-- test/telemetry-init.test.ts | 121 ++++++++++++++++ tsconfig.json | 2 +- 6 files changed, 130 insertions(+), 164 deletions(-) delete mode 100644 src/__tests__/telemetry.test.ts rename {src/tools/__tests__ => test}/memory.test.ts (81%) create mode 100644 test/telemetry-init.test.ts diff --git a/package.json b/package.json index 00aa77c..723b197 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "build": "tsc", "dev": "tsc --watch", "start": "node dist/index.js", - "test": "node --test test/*.test.ts --experimental-strip-types" + "test": "npx tsx --test test/*.test.ts" }, "engines": { "node": ">=20" diff --git a/src/__tests__/telemetry.test.ts b/src/__tests__/telemetry.test.ts deleted file mode 100644 index 7e91f2a..0000000 --- a/src/__tests__/telemetry.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Tests for the telemetry module (src/telemetry.ts). - * - * These tests verify that initTelemetry correctly gates on the OTLP - * endpoint environment variable: it MUST be a no-op when the endpoint - * is not configured, and it MUST successfully create an SDK instance - * when an endpoint (or test provider) is provided. - */ -import { describe, it, before, afterEach } from "node:test"; -import assert from "node:assert/strict"; -import { trace } from "@opentelemetry/api"; -import { - NodeTracerProvider, -} from "@opentelemetry/sdk-trace-node"; -import { - InMemorySpanExporter, - SimpleSpanProcessor, -} from "@opentelemetry/sdk-trace-base"; - -let initTelemetry: typeof import("../telemetry.ts").initTelemetry; -let shutdownTelemetry: typeof import("../telemetry.ts").shutdownTelemetry; -let isTelemetryEnabled: typeof import("../telemetry.ts").isTelemetryEnabled; - -before(async () => { - const mod = await import("../telemetry.ts"); - initTelemetry = mod.initTelemetry; - shutdownTelemetry = mod.shutdownTelemetry; - isTelemetryEnabled = mod.isTelemetryEnabled; -}); - -afterEach(async () => { - // Always clean up telemetry after each test to avoid cross-test - // contamination from global state. - await shutdownTelemetry(); - // trace.disable() unregisters the global tracer provider and - // installs a no-op ProxyTracerProvider, preventing stale spans - // from a previous test's provider leaking into the next test. - try { - trace.disable(); - } catch { - /* ignore */ - } -}); - -describe("telemetry", () => { - // ── Helpers ────────────────────────────────────────────────────── - - function makeTestProvider() { - const exporter = new InMemorySpanExporter(); - const provider = new NodeTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(exporter)], - }); - return { exporter, provider }; - } - - // ── initTelemetry no-op ────────────────────────────────────────── - - it("initTelemetry without OTLP endpoint does not throw and leaves module in a consistent state", async () => { - // Ensure the env var is not set for this test - const saved = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; - const wasSet = "OTEL_EXPORTER_OTLP_ENDPOINT" in process.env; - delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; - - try { - // Calling initTelemetry with no options does not throw — the - // module always wraps its body in try/catch so failures are - // logged, not thrown. - await assert.doesNotReject( - () => initTelemetry({}), - "initTelemetry must never throw, even without an endpoint", - ); - } finally { - if (wasSet) { - process.env.OTEL_EXPORTER_OTLP_ENDPOINT = saved; - } else { - delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; - } - } - }); - - // ── initTelemetry with endpoint ────────────────────────────────── - - it("initTelemetry creates an SDK instance when endpoint is configured", async () => { - // Set a (bogus) OTLP endpoint so the init path proceeds past the - // no-op guard. Because we do not have a real collector, we also - // provide a _testProvider so the test is deterministic. - process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4318"; - const { exporter, provider } = makeTestProvider(); - - try { - await initTelemetry({ - serviceName: "test-svc", - _testProvider: provider, - }); - - assert.equal( - isTelemetryEnabled(), - true, - "telemetry must be enabled after initTelemetry with _testProvider", - ); - - // Verify the provider was actually registered: create a span - // and confirm it flows through the InMemorySpanExporter. - const tracer = trace.getTracer("test"); - const span = tracer.startSpan("test-span"); - span.end(); - - // Force flush so the span lands in the in-memory exporter - // before we read it back. - await provider.forceFlush(); - const spans = exporter.getFinishedSpans(); - assert.equal(spans.length, 1, "span should be exported"); - assert.equal(spans[0].name, "test-span"); - } finally { - delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; - } - }); - - // ── Idempotency ────────────────────────────────────────────────── - - it("initTelemetry is idempotent", async () => { - const { provider: provider1 } = makeTestProvider(); - const { provider: provider2 } = makeTestProvider(); - - await initTelemetry({ _testProvider: provider1 }); - assert.equal(isTelemetryEnabled(), true); - - // Second call should be a no-op — _initialized is already true - await initTelemetry({ _testProvider: provider2 }); - assert.equal(isTelemetryEnabled(), true); - }); - - // ── shutdownTelemetry ──────────────────────────────────────────── - - it("shutdownTelemetry resets the initialized state", async () => { - const { provider } = makeTestProvider(); - - await initTelemetry({ _testProvider: provider }); - assert.equal(isTelemetryEnabled(), true); - - await shutdownTelemetry(); - assert.equal(isTelemetryEnabled(), false); - }); -}); diff --git a/src/telemetry.ts b/src/telemetry.ts index 10cf562..43c72ef 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -71,6 +71,11 @@ const _slots = { export async function initTelemetry(opts: TelemetryOptions): Promise { if (_initialized) return; + // When there is no endpoint configured and no test provider, telemetry + // has nowhere to send data — skip the dynamic SDK imports entirely. + const hasEndpoint = opts.exporterEndpoint || process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + if (!opts._testProvider && !hasEndpoint) return; + try { // Test path — register a caller-supplied TracerProvider directly. if (opts._testProvider) { diff --git a/src/tools/__tests__/memory.test.ts b/test/memory.test.ts similarity index 81% rename from src/tools/__tests__/memory.test.ts rename to test/memory.test.ts index c02f5e0..76ce582 100644 --- a/src/tools/__tests__/memory.test.ts +++ b/test/memory.test.ts @@ -12,45 +12,38 @@ import { join } from "path"; import { tmpdir } from "os"; import { execSync } from "child_process"; -let createMemoryTool: typeof import("../memory.ts").createMemoryTool; +let createMemoryTool: typeof import("../src/tools/memory.ts").createMemoryTool; before(async () => { - const mod = await import("../memory.ts"); + const mod = await import("../src/tools/memory.ts"); createMemoryTool = mod.createMemoryTool; }); describe("memory tool", () => { - /** Create a temporary directory with git init and return the path. */ async function setupRepo(): Promise { const dir = await mkdtemp(join(tmpdir(), "gitagent-memory-test-")); execSync("git init -q", { cwd: dir }); - // Configure git user for commits execSync('git config --local user.email "test@gitagent.test"', { cwd: dir }); execSync('git config --local user.name "Test Agent"', { cwd: dir }); return dir; } - /** Clean up a temp directory. */ async function cleanup(dir: string): Promise { await rm(dir, { recursive: true, force: true }).catch(() => {}); } - // ── load ───────────────────────────────────────────────────────── - describe("load", () => { it("returns stored memory content", async () => { const dir = await setupRepo(); try { const tool = createMemoryTool(dir); - // First, save some memory content await tool.execute("call-1", { action: "save", content: "# Memory\n\n- Remember to buy milk\n- Project uses TypeScript", message: "Initial memory", }); - // Now load it const result = await tool.execute("call-2", { action: "load" }); assert.ok(result.content); @@ -67,7 +60,6 @@ describe("memory tool", () => { try { const tool = createMemoryTool(dir); - // Load from a repo with no memory file const result = await tool.execute("call-1", { action: "load" }); assert.equal(result.content[0].text, "No memories yet."); @@ -79,7 +71,6 @@ describe("memory tool", () => { it("returns 'No memories yet.' when memory file has only heading", async () => { const dir = await setupRepo(); try { - // Write the default heading-only memory file await mkdir(join(dir, "memory"), { recursive: true }); await writeFile(join(dir, "memory", "MEMORY.md"), "# Memory", "utf-8"); @@ -93,8 +84,6 @@ describe("memory tool", () => { }); }); - // ── save ───────────────────────────────────────────────────────── - describe("save", () => { it("writes content and commits to git", async () => { const dir = await setupRepo(); @@ -113,7 +102,6 @@ describe("memory tool", () => { ); assert.ok(result.content[0].text.includes("First save")); - // Verify the file was written const { readFile } = await import("fs/promises"); const fileContent = await readFile( join(dir, "memory", "MEMORY.md"), @@ -121,7 +109,6 @@ describe("memory tool", () => { ); assert.ok(fileContent.includes("Saved entry one")); - // Verify the git commit exists const log = execSync("git log --oneline", { cwd: dir, encoding: "utf-8", @@ -164,7 +151,6 @@ describe("memory tool", () => { () => tool.execute("call-1", { action: "save", - // content intentionally omitted }), /content is required for save action/, ); @@ -174,8 +160,6 @@ describe("memory tool", () => { }); }); - // ── abort signal ───────────────────────────────────────────────── - describe("abort signal", () => { it("throws when signal is already aborted", async () => { const dir = await setupRepo(); diff --git a/test/telemetry-init.test.ts b/test/telemetry-init.test.ts new file mode 100644 index 0000000..6d09184 --- /dev/null +++ b/test/telemetry-init.test.ts @@ -0,0 +1,121 @@ +/** + * Tests for the telemetry module (src/telemetry.ts) — init/shutdown/idempotency. + * + * These tests verify that initTelemetry correctly gates on the OTLP + * endpoint environment variable: it MUST return without enabling telemetry + * when no endpoint is configured, and it MUST successfully create an SDK + * instance when an endpoint (or test provider) is provided. + */ +import { describe, it, before, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { trace } from "@opentelemetry/api"; +import { + NodeTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from "@opentelemetry/sdk-trace-node"; + +let initTelemetry: typeof import("../src/telemetry.ts").initTelemetry; +let shutdownTelemetry: typeof import("../src/telemetry.ts").shutdownTelemetry; +let isTelemetryEnabled: typeof import("../src/telemetry.ts").isTelemetryEnabled; + +before(async () => { + const mod = await import("../src/telemetry.ts"); + initTelemetry = mod.initTelemetry; + shutdownTelemetry = mod.shutdownTelemetry; + isTelemetryEnabled = mod.isTelemetryEnabled; +}); + +afterEach(async () => { + await shutdownTelemetry(); + try { + trace.disable(); + } catch { + /* ignore */ + } +}); + +describe("telemetry init", () => { + function makeTestProvider() { + const exporter = new InMemorySpanExporter(); + const provider = new NodeTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(exporter)], + }); + return { exporter, provider }; + } + + it("returns without enabling telemetry when no OTLP endpoint is configured", async () => { + const saved = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + const wasSet = "OTEL_EXPORTER_OTLP_ENDPOINT" in process.env; + delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + + try { + await assert.doesNotReject( + () => initTelemetry({}), + "initTelemetry must never throw, even without an endpoint", + ); + + assert.equal( + isTelemetryEnabled(), + false, + "telemetry must remain disabled when no endpoint is configured", + ); + } finally { + if (wasSet) { + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = saved; + } else { + delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + } + } + }); + + it("creates an SDK instance when endpoint is configured", async () => { + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4318"; + const { exporter, provider } = makeTestProvider(); + + try { + await initTelemetry({ + serviceName: "test-svc", + _testProvider: provider, + }); + + assert.equal( + isTelemetryEnabled(), + true, + "telemetry must be enabled after initTelemetry with _testProvider", + ); + + const tracer = trace.getTracer("test"); + const span = tracer.startSpan("test-span"); + span.end(); + + await provider.forceFlush(); + const spans = exporter.getFinishedSpans(); + assert.equal(spans.length, 1, "span should be exported"); + assert.equal(spans[0].name, "test-span"); + } finally { + delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + } + }); + + it("is idempotent", async () => { + const { provider: provider1 } = makeTestProvider(); + const { provider: provider2 } = makeTestProvider(); + + await initTelemetry({ _testProvider: provider1 }); + assert.equal(isTelemetryEnabled(), true); + + await initTelemetry({ _testProvider: provider2 }); + assert.equal(isTelemetryEnabled(), true); + }); + + it("shutdownTelemetry resets the initialized state", async () => { + const { provider } = makeTestProvider(); + + await initTelemetry({ _testProvider: provider }); + assert.equal(isTelemetryEnabled(), true); + + await shutdownTelemetry(); + assert.equal(isTelemetryEnabled(), false); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index be84a50..64429fa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,5 +13,5 @@ "resolveJsonModule": true }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/__tests__"] + "exclude": ["node_modules", "dist"] }