diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94710b2..c0318fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: - node-version: [22.x] + node-version: [22.x, 24.x] steps: - name: ๐Ÿ“ฅ Checkout code @@ -31,3 +31,40 @@ jobs: - name: ๐Ÿ“Š Test coverage run: npm run test:coverage + + pack-smoke: + name: ๐Ÿ“ฆ Pack smoke + runs-on: ubuntu-latest + + steps: + - name: ๐Ÿ“ฅ Checkout code + uses: actions/checkout@v7 + + - name: ๐Ÿ”ง Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22.x + cache: 'npm' + + - name: ๐Ÿ“ฆ Install dependencies + run: npm ci + + - name: ๐Ÿ—๏ธ Build + run: npm run build + + - name: ๐Ÿ“ฆ Pack the publish tarball + run: | + mkdir -p /tmp/pack + npm pack --pack-destination /tmp/pack + + - name: ๐Ÿš€ Install the packed CLI and smoke the binary + run: | + set -euo pipefail + tarball=$(ls /tmp/pack/*.tgz) + expected=$(node -p "require('./package.json').version") + npm i -g "$tarball" --prefix /tmp/global + export PATH="/tmp/global/bin:$PATH" + got=$(agentage --version) + echo "installed CLI reports $got (expected $expected)" + test "$got" = "$expected" + agentage --help > /dev/null diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e5c71cc..c547945 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,7 +1,10 @@ -# E2E - builds the CLI and runs ./e2e against a live deployed stack (default -# dev). Self-contained: when E2E_AUTH_* secrets are absent, the @p0 round trip -# signs up a throwaway account (open signup on dev). No browser binaries needed -# (request-only tests). +# E2E - builds the CLI and runs ./e2e against the built dist/cli.js. Two tiers: +# - e2e-offline: the @offline-tagged tests only. Self-contained (temp bare +# remotes, no live stack), so this is the fast required signal even when +# dev.agentage.io is down. No live-stack env or secrets. +# - e2e-live: the full suite against a live deployed stack (default dev). +# Self-contained @p0 OAuth: when E2E_AUTH_* secrets are absent it signs up a +# throwaway account (open signup on dev). No browser binaries needed. name: E2E on: @@ -16,7 +19,38 @@ on: default: 'dev.agentage.io' jobs: - e2e: + e2e-offline: + name: ๐Ÿงช E2E offline (no live stack) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: ๐Ÿ“ฅ Checkout code + uses: actions/checkout@v7 + + - name: ๐Ÿ”ง Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: 'npm' + + - name: ๐Ÿ“ฆ Install dependencies + run: npm ci + + - name: ๐Ÿ—๏ธ Build + run: npm run build + + - name: ๐Ÿงช Run offline e2e + run: npm run test:e2e -- --grep @offline + + - name: ๐Ÿ“Š Upload report + if: failure() + uses: actions/upload-artifact@v7 + with: + name: e2e-offline-report + path: playwright-report/ + retention-days: 7 + + e2e-live: name: ๐Ÿงช E2E vs ${{ inputs.target || 'dev.agentage.io' }} runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/e2e/account-vault.test.ts b/e2e/account-vault.test.ts index 418dd7f..4849273 100644 --- a/e2e/account-vault.test.ts +++ b/e2e/account-vault.test.ts @@ -20,7 +20,7 @@ interface AccountEntry { type?: string; } -test.describe('account vault (offline) @p0', () => { +test.describe('account vault (offline) @p0 @offline', () => { test.beforeAll(() => assertCliBuilt()); test('no-flag add registers an account vault locally, then reads/writes it, with no network', async () => { diff --git a/e2e/daemon.test.ts b/e2e/daemon.test.ts index 8e82cb0..6657db6 100644 --- a/e2e/daemon.test.ts +++ b/e2e/daemon.test.ts @@ -27,7 +27,7 @@ const servedCount = (statusOut: string): number => { return m ? Number.parseInt(m[1], 10) : -1; }; -test.describe('daemon owns the engine @p0', () => { +test.describe('daemon owns the engine @p0 @offline', () => { test.beforeAll(() => assertCliBuilt()); test('start -> six verbs through the daemon -> stop', async () => { diff --git a/e2e/integrity.test.ts b/e2e/integrity.test.ts index 9faa600..7916e45 100644 --- a/e2e/integrity.test.ts +++ b/e2e/integrity.test.ts @@ -80,7 +80,7 @@ const execStdin = (configDir: string, args: string[], stdin: string): Promise { +test.describe('store data integrity @p0 @offline', () => { test.beforeAll(() => assertCliBuilt()); test('10 concurrent writes serialize into 10 clean commits', async () => { diff --git a/e2e/m1-requirements.test.ts b/e2e/m1-requirements.test.ts index 22fdbe0..e8b12ce 100644 --- a/e2e/m1-requirements.test.ts +++ b/e2e/m1-requirements.test.ts @@ -7,7 +7,7 @@ import { assertCliBuilt, createCliMachine } from './helpers.js'; // registry acceptance criteria end to end. All offline except `update --check`. @p0 const SCHEMA_URL = 'https://agentage.io/schemas/vaults.schema.json'; -test.describe('config + vault registry (offline) @p0', () => { +test.describe('config + vault registry (offline) @p0 @offline', () => { test.beforeAll(() => assertCliBuilt()); test('add --git writes a valid origin entry with the $schema link', async () => { diff --git a/e2e/mcp-daemon.test.ts b/e2e/mcp-daemon.test.ts index 8f59906..7e457b8 100644 --- a/e2e/mcp-daemon.test.ts +++ b/e2e/mcp-daemon.test.ts @@ -48,7 +48,7 @@ const mcpRpc = async (port: number, method: string, params: unknown): Promise): Promise => mcpRpc(port, 'tools/call', { name, arguments: args }); -test.describe('daemon /mcp exposes the frozen tools @p0', () => { +test.describe('daemon /mcp exposes the frozen tools @p0 @offline', () => { test.beforeAll(() => assertCliBuilt()); test('initialize + tools/list + all six tools over :port/mcp', async () => { diff --git a/e2e/mcp-stdio.test.ts b/e2e/mcp-stdio.test.ts index 2296021..bff1121 100644 --- a/e2e/mcp-stdio.test.ts +++ b/e2e/mcp-stdio.test.ts @@ -63,7 +63,7 @@ const startStdioMcp = (configDir: string): StdioMcp => { }; }; -test.describe('agentage mcp over stdio @p0', () => { +test.describe('agentage mcp over stdio @p0 @offline', () => { test.beforeAll(() => assertCliBuilt()); test('initialize + tools/list + a tool call, clean EOF shutdown', async () => { diff --git a/e2e/memory-offline.test.ts b/e2e/memory-offline.test.ts index a8f99cc..abd872c 100644 --- a/e2e/memory-offline.test.ts +++ b/e2e/memory-offline.test.ts @@ -23,7 +23,7 @@ interface TreeEntry { const treeFiles = (entries: TreeEntry[]): string[] => entries.flatMap((e) => (e.type === 'file' ? [e.path] : e.entries ? treeFiles(e.entries) : [])); -test.describe('offline memory CRUD @p0', () => { +test.describe('offline memory CRUD @p0 @offline', () => { test.beforeAll(() => assertCliBuilt()); test('write -> search -> read -> edit -> list -> delete, no network', async () => { diff --git a/e2e/status.test.ts b/e2e/status.test.ts index e9f0c05..c5a572f 100644 --- a/e2e/status.test.ts +++ b/e2e/status.test.ts @@ -19,7 +19,7 @@ test.beforeAll(() => { test.afterAll(() => machine.cleanup()); -test('version matches the package version @smoke', async () => { +test('version matches the package version @smoke @offline', async () => { const result = await machine.exec(['--version']); expect(result.code, result.stderr).toBe(0); expect(result.stdout.trim()).toBe(pkg.version); @@ -32,7 +32,7 @@ test('unauthenticated status degrades with a setup hint @smoke', async () => { expect(report.endpoint.reachable, `endpoint ${report.endpoint.url} unreachable`).toBe(true); }); -test('status never leaks a stack trace when signed out @smoke', async () => { +test('status never leaks a stack trace when signed out @smoke @offline', async () => { const result = await machine.exec(['status']); expect(result.code).toBe(0); expect(result.stdout).toContain('not signed in - run: agentage setup'); diff --git a/e2e/sync.test.ts b/e2e/sync.test.ts index 3a0b4ed..5540bb1 100644 --- a/e2e/sync.test.ts +++ b/e2e/sync.test.ts @@ -58,7 +58,7 @@ const alive = (pid: number): boolean => { } }; -test.describe('git sync @p0', () => { +test.describe('git sync @p0 @offline', () => { test.beforeAll(() => assertCliBuilt()); test('the daemon auto-loop pushes a write to the bare remote within one interval', async () => { diff --git a/e2e/vault-offline.test.ts b/e2e/vault-offline.test.ts index 06b704b..c5b73d3 100644 --- a/e2e/vault-offline.test.ts +++ b/e2e/vault-offline.test.ts @@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test'; import { assertCliBuilt, createCliMachine } from './helpers.js'; // The offline registry on the unified vaults.json: add/list/remove work with no network. @p0 -test.describe('offline vault registry @p0', () => { +test.describe('offline vault registry @p0 @offline', () => { test.beforeAll(() => assertCliBuilt()); test('add --local -> list -> remove round-trips with no network', async () => { diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 3d74246..9771448 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { authedGet, authedPost, AuthRequiredError, introspectToken } from './api.js'; +import { authedGet, authedPost, AuthRequiredError, currentBearer, introspectToken } from './api.js'; import { readAuth, type AuthState } from './config.js'; import { links } from './origins.js'; @@ -134,6 +134,77 @@ describe('authedPost', () => { }); }); +describe('currentBearer', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'agentage-api-')); + process.env['AGENTAGE_CONFIG_DIR'] = dir; + }); + + afterEach(() => { + delete process.env['AGENTAGE_CONFIG_DIR']; + rmSync(dir, { recursive: true, force: true }); + vi.unstubAllGlobals(); + }); + + it('returns a valid unexpired token as-is, with zero network', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const auth = makeAuth({ accessToken: 'live-token', expiresAt: Date.now() + 60_000 }); + const bearer = await currentBearer(() => auth, target); + expect(bearer).toBe('live-token'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('refreshes exactly once on an expired token, returns and persists the new bearer', async () => { + const fetchMock = vi.fn((url: string) => + Promise.resolve( + String(url).includes('/token') + ? jsonResponse(200, { + access_token: 'fresh-token', + refresh_token: 'rt2', + expires_in: 3600, + }) + : jsonResponse(500, {}) + ) + ); + vi.stubGlobal('fetch', fetchMock); + const auth = makeAuth({ accessToken: 'stale-token', expiresAt: Date.now() - 1000 }); + const bearer = await currentBearer(() => auth, target); + expect(bearer).toBe('fresh-token'); + const tokenCalls = fetchMock.mock.calls.filter((c) => String(c[0]).includes('/token')); + expect(tokenCalls).toHaveLength(1); + expect(readAuth()?.tokens.accessToken).toBe('fresh-token'); + }); + + it('returns null (never throws) when the refresh fails', async () => { + const fetchMock = vi.fn((url: string) => + Promise.resolve( + String(url).includes('/token') ? jsonResponse(400, {}) : jsonResponse(200, {}) + ) + ); + vi.stubGlobal('fetch', fetchMock); + const auth = makeAuth({ accessToken: 'stale-token', expiresAt: Date.now() - 1000 }); + await expect(currentBearer(() => auth, target)).resolves.toBeNull(); + }); + + it('returns null with no refresh attempt when an expired token has no refresh token', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const auth = makeAuth({ expiresAt: Date.now() - 1000, refreshToken: undefined }); + await expect(currentBearer(() => auth, target)).resolves.toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns null when signed out (no stored access token)', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + await expect(currentBearer(() => null, target)).resolves.toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + describe('introspectToken', () => { afterEach(() => vi.unstubAllGlobals()); diff --git a/src/sync/couch/manager.test.ts b/src/sync/couch/manager.test.ts index 00d536e..91b7154 100644 --- a/src/sync/couch/manager.test.ts +++ b/src/sync/couch/manager.test.ts @@ -57,6 +57,15 @@ const makeManager = (over: Partial = {}) => { return { mgr, couch, discovery }; }; +const autoConfig: VaultsConfig = { + version: 1, + default: 'acct', + vaults: { + acct: { path: '/tmp/acct', origin: [{ remote: 'agentage', interval: 300 }] }, + two: { path: '/tmp/two', origin: [{ remote: 'agentage', interval: 300 }] }, + }, +}; + describe('resolveMutationTarget', () => { it('maps a bare ref to the default vault when it is an account vault', () => { expect(resolveMutationTarget(config, { ref: 'notes/x.md' })).toEqual({ @@ -161,6 +170,28 @@ describe('createCouchSyncManager.onWrite', () => { expect(couch.removeFile).not.toHaveBeenCalled(); }); + it('defers a write to the pending queue when signed in but the channel is not couch-ready', async () => { + const pending: string[] = []; + const { mgr, couch } = makeManager({ + discovery: { + channelFor: vi.fn(async (): Promise => ({ + kind: 'paused', + reason: 'not provisioned', + })), + reset: vi.fn(), + }, + makeStatePersistence: () => ({ + load: async () => null, + save: async (s) => { + pending.push(...s.pending); + }, + }), + }); + mgr.onWrite('write', { ref: 'notes/y.md' }); + await vi.waitFor(() => expect(pending).toContain('notes/y.md')); + expect(couch.pushFileLive).not.toHaveBeenCalled(); + }); + it('a delete surviving a discovery failure is enqueued, never dropped', async () => { const deletions: string[] = []; const { mgr, couch } = makeManager({ @@ -264,4 +295,105 @@ describe('createCouchSyncManager.status and runNow', () => { couch.syncNow.mock.invocationCallOrder[0]! ); }); + + it('records a token-acquisition failure as an error, never throwing', async () => { + const { mgr, couch } = makeManager({ + getBearer: async () => { + throw new Error('token endpoint down'); + }, + }); + const result = await mgr.runNow('acct'); + expect(result).toMatchObject({ ok: false, error: 'token endpoint down' }); + expect(couch.syncNow).not.toHaveBeenCalled(); + expect(mgr.status()[0]!.lastError).toBe('token endpoint down'); + }); + + it('pauses (no sync) when discovery reports the channel is not ready', async () => { + const { mgr, couch } = makeManager({ + discovery: { + channelFor: vi.fn(async (): Promise => ({ + kind: 'paused', + reason: 'not provisioned', + })), + reset: vi.fn(), + }, + }); + const result = await mgr.runNow('acct'); + expect(result).toMatchObject({ ok: true, paused: 'not provisioned' }); + expect(couch.syncNow).not.toHaveBeenCalled(); + expect(mgr.status()[0]!.paused).toBe('not provisioned'); + }); + + it('a re-entrant cycle on an already-running target is a no-op', async () => { + let release: () => void = () => {}; + const gate = new Promise((r) => (release = r)); + const couch = { + pushFileLive: vi.fn(async () => {}), + removeFile: vi.fn(async () => {}), + flushPending: vi.fn(async () => {}), + syncNow: vi.fn(async () => { + await gate; + return { pushed: true, pulled: true }; + }), + }; + const { mgr } = makeManager({ makeCouchSync: () => couch }); + const first = mgr.runNow('acct'); + const second = await mgr.runNow('acct'); // guard hit while first still holds the target + expect(second.pendingCount).toBe(0); + release(); + await first; + expect(couch.syncNow).toHaveBeenCalledTimes(1); // only the first cycle ran the round + }); + + it('reuses the cached couch wire and target state across repeated cycles', async () => { + const couch = { + pushFileLive: vi.fn(async () => {}), + removeFile: vi.fn(async () => {}), + flushPending: vi.fn(async () => {}), + syncNow: vi.fn(async () => ({ pushed: true, pulled: true })), + }; + const makeCouchSync = vi.fn(() => couch); + const mgr = createCouchSyncManager({ + getConfig: () => config, + configDir: () => '/tmp/cfg', + getBearer: async () => 'tok', + discovery: { channelFor: vi.fn(async () => couchDecision), reset: vi.fn() }, + makeCouchSync, + makeFileStore: noopStore, + makeStatePersistence: () => ({ load: async () => null, save: async () => {} }), + commitDirty: async () => ({ committed: false, skipped: false }), + now: () => '2026-01-01T00:00:00Z', + }); + await mgr.runNow('acct'); + await mgr.runNow('acct'); + expect(makeCouchSync).toHaveBeenCalledTimes(1); + expect(couch.syncNow).toHaveBeenCalledTimes(2); + }); +}); + +describe('createCouchSyncManager.reschedule and stop', () => { + it('registers a state per couch target and arms auto-loop timers', () => { + const { mgr } = makeManager({ getConfig: () => autoConfig }); + mgr.reschedule(); + expect(mgr.status().map((s) => s.vault)).toEqual(['acct', 'two']); + mgr.stop(); + }); + + it('prunes the state of a vault dropped from the config on the next reschedule', () => { + let cfg: VaultsConfig = autoConfig; + const { mgr } = makeManager({ getConfig: () => cfg }); + mgr.reschedule(); + expect(mgr.status()).toHaveLength(2); + cfg = config; // only 'acct' remains a couch target + mgr.reschedule(); + expect(mgr.status().map((s) => s.vault)).toEqual(['acct']); + mgr.stop(); + }); + + it('does not arm a timer for a manual-only (interval 0) target', () => { + const { mgr } = makeManager(); + mgr.reschedule(); + expect(mgr.status().map((s) => s.vault)).toEqual(['acct']); + mgr.stop(); + }); }); diff --git a/vitest.config.ts b/vitest.config.ts index a5d876e..2651933 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,6 +12,10 @@ export default defineConfig({ functions: 70, lines: 70, statements: 70, + // Per-directory floors so a single weak file cannot hide behind the global average. + // Set a few points below the achieved numbers; raise as coverage grows. + 'src/lib/**': { branches: 78, functions: 83, lines: 90, statements: 87 }, + 'src/sync/**': { branches: 75, functions: 75, lines: 87, statements: 85 }, }, }, },