Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:

strategy:
matrix:
node-version: [22.x]
node-version: [22.x, 24.x]

steps:
- name: 📥 Checkout code
Expand All @@ -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
44 changes: 39 additions & 5 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion e2e/account-vault.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion e2e/daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion e2e/integrity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ const execStdin = (configDir: string, args: string[], stdin: string): Promise<Ex
child.stdin.end();
});

test.describe('store data integrity @p0', () => {
test.describe('store data integrity @p0 @offline', () => {
test.beforeAll(() => assertCliBuilt());

test('10 concurrent writes serialize into 10 clean commits', async () => {
Expand Down
2 changes: 1 addition & 1 deletion e2e/m1-requirements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion e2e/mcp-daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const mcpRpc = async (port: number, method: string, params: unknown): Promise<Rp
const callTool = (port: number, name: string, args: Record<string, unknown>): Promise<RpcResult> =>
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 () => {
Expand Down
2 changes: 1 addition & 1 deletion e2e/mcp-stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion e2e/memory-offline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
4 changes: 2 additions & 2 deletions e2e/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion e2e/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion e2e/vault-offline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
73 changes: 72 additions & 1 deletion src/lib/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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());

Expand Down
Loading