diff --git a/packages/agent-gateway/package.json b/packages/agent-gateway/package.json index e88403d3a..8a9860d9c 100644 --- a/packages/agent-gateway/package.json +++ b/packages/agent-gateway/package.json @@ -22,6 +22,7 @@ "build": "tsdown", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run", + "test:bench": "vitest run --config vitest.bench.config.ts", "clean": "rm -rf dist" }, "dependencies": { diff --git a/packages/agent-gateway/test/apiSurface.snapshot.test.ts b/packages/agent-gateway/test/apiSurface.snapshot.test.ts index 2ba4a6575..ba684eb0a 100644 --- a/packages/agent-gateway/test/apiSurface.snapshot.test.ts +++ b/packages/agent-gateway/test/apiSurface.snapshot.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { startServer, type RunningServer } from '../src'; import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; @@ -25,7 +25,20 @@ describe('API surface snapshot', () => { let home: string | undefined; let server: RunningServer | undefined; - afterEach(async () => { + beforeAll(async () => { + home = mkdtempSync(join(tmpdir(), 'pythinker-server-v2-api-surface-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + debugEndpoints: true, + env: { PYTHINKER_CODE_EXPERIMENTAL_MCP_MANAGEMENT: '1' }, + }); + }); + + afterAll(async () => { if (server !== undefined) { try { await server.close(); @@ -40,21 +53,9 @@ describe('API surface snapshot', () => { }); it('matches the documented v2 route table and meta endpoints', async () => { - home = mkdtempSync(join(tmpdir(), 'pythinker-server-v2-api-surface-')); - - server = await startServer({ - hostIdentity: TEST_HOST_IDENTITY, - host: '127.0.0.1', - port: 0, - homeDir: home, - logLevel: 'silent', - debugEndpoints: true, - env: { PYTHINKER_CODE_EXPERIMENTAL_MCP_MANAGEMENT: '1' }, - }); - - const base = `http://${server.host}:${server.port}`; + const base = `http://${server!.host}:${server!.port}`; - const openApiRes = await fetch(`${base}/openapi.json`, { headers: authHeaders(server) } as never); + const openApiRes = await fetch(`${base}/openapi.json`, { headers: authHeaders(server as RunningServer) } as never); expect(openApiRes.status).toBe(200); const openApi = (await openApiRes.json()) as { paths?: Record>; @@ -74,7 +75,7 @@ describe('API surface snapshot', () => { const meta: Array<[string, string, number]> = []; for (const endpoint of META_ENDPOINTS) { - const res = await fetch(`${base}${endpoint}`, { headers: authHeaders(server) } as never); + const res = await fetch(`${base}${endpoint}`, { headers: authHeaders(server as RunningServer) } as never); meta.push(['GET', endpoint, res.status]); } meta.sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]) || a[2] - b[2]); diff --git a/packages/agent-gateway/test/approvals.test.ts b/packages/agent-gateway/test/approvals.test.ts index 833d129f2..d30b9f718 100644 --- a/packages/agent-gateway/test/approvals.test.ts +++ b/packages/agent-gateway/test/approvals.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { ISessionApprovalService, ensureMainAgent, getLiveSessionById } from '@pymodel/agent-core-v2'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it, afterAll } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; @@ -43,7 +43,7 @@ describe('server-v2 /api/v1/sessions/{sid}/approvals', () => { let home: string | undefined; let base: string; - beforeEach(async () => { + beforeAll(async () => { home = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-approvals-')); server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, @@ -55,7 +55,7 @@ describe('server-v2 /api/v1/sessions/{sid}/approvals', () => { base = `http://127.0.0.1:${server.port}`; }); - afterEach(async () => { + afterAll(async () => { if (server !== undefined) { await server.close(); server = undefined; @@ -174,7 +174,7 @@ describe('server-v2 /api/v1/sessions/{sid}/approvals', () => { expect(first).not.toBe(second); const { body } = await getJson(`/api/v1/sessions/${sid}/approvals?status=pending`); - expect(body.data.items.map((i) => i.approval_id).sort()).toEqual([first, second].sort()); + expect(body.data.items.map((i) => i.approval_id).toSorted()).toEqual([first, second].toSorted()); expect(body.data.items.every((i) => i.tool_call_id === 'Bash_0')).toBe(true); for (const aid of [first, second]) { diff --git a/packages/agent-gateway/test/capabilities.test.ts b/packages/agent-gateway/test/capabilities.test.ts index 6bd988ad2..b0f0a89b9 100644 --- a/packages/agent-gateway/test/capabilities.test.ts +++ b/packages/agent-gateway/test/capabilities.test.ts @@ -1,13 +1,7 @@ -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { listCapabilitiesResponseSchema } from '../src/protocol/rest-capability'; -import { type RunningServer, startServer } from '../src/start'; -import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; -import { authHeaders } from './helpers/auth'; +import { sharedAuthHeaders, sharedServer } from './helpers/sharedServer'; interface Envelope { code: number; @@ -17,44 +11,17 @@ interface Envelope { } describe('server-v2 /api/v1 capabilities', () => { - let server: RunningServer | undefined; - let home: string | undefined; - let base: string; - - beforeEach(async () => { - home = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-capabilities-')); - server = await startServer({ - hostIdentity: TEST_HOST_IDENTITY, - host: '127.0.0.1', - port: 0, - homeDir: home, - logLevel: 'silent', - }); - base = `http://127.0.0.1:${server.port}`; - }); - - afterEach(async () => { - if (server !== undefined) { - await server.close(); - server = undefined; - } - if (home !== undefined) { - await rm(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 } as never); - home = undefined; - } - }); - async function getJson(path: string): Promise<{ status: number; body: Envelope }> { - const res = await fetch(`${base}${path}`, { - headers: authHeaders(server as RunningServer), + const res = await fetch(`${sharedServer().base}${path}`, { + headers: sharedAuthHeaders(), } as never); return { status: res.status, body: (await res.json()) as Envelope }; } async function postJson(path: string): Promise<{ status: number; body: Envelope }> { - const res = await fetch(`${base}${path}`, { + const res = await fetch(`${sharedServer().base}${path}`, { method: 'POST', - headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + headers: sharedAuthHeaders({ 'content-type': 'application/json' }), body: '{}', } as never); return { status: res.status, body: (await res.json()) as Envelope }; diff --git a/packages/agent-gateway/test/connections.test.ts b/packages/agent-gateway/test/connections.test.ts index 59cf32911..ac43c3e87 100644 --- a/packages/agent-gateway/test/connections.test.ts +++ b/packages/agent-gateway/test/connections.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { connectionsListResponseSchema } from '../src/protocol/rest-connection'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it, afterAll } from 'vitest'; import { WebSocket } from 'ws'; import { type RunningServer, startServer } from '../src/start'; @@ -23,14 +23,14 @@ describe('server-v2 GET /api/v1/connections', () => { let base: string; let wsUrl: string; - beforeEach(async () => { + beforeAll(async () => { home = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-connections-')); server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); base = `http://127.0.0.1:${server.port}`; wsUrl = `ws://127.0.0.1:${server.port}/api/v1/ws`; }); - afterEach(async () => { + afterAll(async () => { if (server !== undefined) { await server.close(); server = undefined; diff --git a/packages/agent-gateway/test/fs.test.ts b/packages/agent-gateway/test/fs.test.ts index 7b5d227c7..75547c804 100644 --- a/packages/agent-gateway/test/fs.test.ts +++ b/packages/agent-gateway/test/fs.test.ts @@ -1,4 +1,4 @@ -import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, sep } from 'node:path'; @@ -6,11 +6,12 @@ import { IModelCatalog, IWorkspaceInstanceManager } from '@pymodel/agent-core-v2 import { HostFileSystem } from '@pymodel/agent-core-v2/os/backends/node-local/hostFsService'; import { FakeRuntime } from '@pymodel/agent-core-v2/runtime/fakeRuntime'; import { ErrorCode } from '../src/protocol/error-codes'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; +import { fakeModelCatalog } from './helpers/fakeModelCatalog'; interface Envelope { code: number; @@ -36,45 +37,31 @@ describe('server-v2 /api/v1 fs routes', () => { let work: string | undefined; let base: string; - beforeEach(async () => { + beforeAll(async () => { home = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-fs-home-')); - work = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-fs-work-')); - const modelCatalog: IModelCatalog = { - _serviceBrand: undefined, - get: () => { - throw new Error('modelCatalog.get not exercised in this test'); - }, - getRequester: () => { - throw new Error('modelCatalog.getRequester not exercised in this test'); - }, - inspect: () => { - throw new Error('modelCatalog.inspect not exercised in this test'); - }, - ping: () => { - throw new Error('modelCatalog.ping not exercised in this test'); - }, - findByName: () => [], - listModels: async () => [], - listProviders: async () => [], - getProvider: async () => { - throw new Error('modelCatalog.getProvider not exercised in this test'); - }, - setDefaultModel: async () => { - throw new Error('modelCatalog.setDefaultModel not exercised in this test'); - }, - }; server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent', - seeds: [[IModelCatalog, modelCatalog]], + seeds: [[IModelCatalog, fakeModelCatalog()]], }); base = `http://127.0.0.1:${server.port}`; }); + beforeEach(async () => { + work = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-fs-work-')); + }); + afterEach(async () => { + if (work !== undefined) { + await rm(work, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + work = undefined; + } + }); + + afterAll(async () => { if (server !== undefined) { await server.close(); server = undefined; @@ -83,10 +70,6 @@ describe('server-v2 /api/v1 fs routes', () => { await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); home = undefined; } - if (work !== undefined) { - await rm(work, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); - work = undefined; - } }); async function createSession(): Promise { @@ -701,15 +684,21 @@ describe('server-v2 /api/v1 fs routes', () => { expect(body.code).toBe(0); expect(body.data.items.map((i) => i.path)).toContain('kappa.ts'); - expect(await listWorkspaces()).toEqual([]); - expect(server!.core.accessor.get(IWorkspaceInstanceManager).list()).toEqual([]); + const workAliases = new Set([work!, await realpath(work!)]); + expect((await listWorkspaces()).some((w) => workAliases.has(w.root))).toBe(false); + expect( + server!.core.accessor + .get(IWorkspaceInstanceManager) + .list() + .some((w) => workAliases.has(w.root)), + ).toBe(false); const again = await postRootSuggest<{ items: SuggestItemWire[] }>({ roots: [work], query: 'kappa', }); expect(again.code).toBe(0); - expect(await listWorkspaces()).toEqual([]); + expect((await listWorkspaces()).some((w) => workAliases.has(w.root))).toBe(false); }); it('fs:suggest matches the workspace route for the same single root', async () => { diff --git a/packages/agent-gateway/test/globalSetup.ts b/packages/agent-gateway/test/globalSetup.ts new file mode 100644 index 000000000..b7b4f5368 --- /dev/null +++ b/packages/agent-gateway/test/globalSetup.ts @@ -0,0 +1,36 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { IModelCatalog } from '@pymodel/agent-core-v2'; +import type { TestProject } from 'vitest/node'; + +import { startServer } from '../src/start'; +import { fakeModelCatalog } from './helpers/fakeModelCatalog'; +import { fixedTokenAuth } from './helpers/fixedAuth'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; + +export const SHARED_SERVER_TOKEN = 'test-token'; + +export default async function globalSetup(project: TestProject): Promise<() => Promise> { + process.env['PYTHINKER_CODE_SEARCH_WORKER'] = 'false'; + process.env['PYTHINKER_CODE_PERSISTENCE_MINIDB_READMODEL'] = 'false'; + const home = await mkdtemp(join(tmpdir(), 'pythinker-agent-gateway-shared-home-')); + const server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + authTokenService: fixedTokenAuth(SHARED_SERVER_TOKEN), + seeds: [[IModelCatalog, fakeModelCatalog()]], + }); + project.provide('sharedServer', { + base: `http://127.0.0.1:${server.port}`, + token: SHARED_SERVER_TOKEN, + }); + return async () => { + await server.close(); + await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + }; +} diff --git a/packages/agent-gateway/test/helpers/fakeModelCatalog.ts b/packages/agent-gateway/test/helpers/fakeModelCatalog.ts new file mode 100644 index 000000000..a4af806a9 --- /dev/null +++ b/packages/agent-gateway/test/helpers/fakeModelCatalog.ts @@ -0,0 +1,28 @@ +import { IModelCatalog } from '@pymodel/agent-core-v2'; + +export function fakeModelCatalog(): IModelCatalog { + return { + _serviceBrand: undefined, + get: () => { + throw new Error('modelCatalog.get not exercised in this test'); + }, + getRequester: () => { + throw new Error('modelCatalog.getRequester not exercised in this test'); + }, + inspect: () => { + throw new Error('modelCatalog.inspect not exercised in this test'); + }, + ping: () => { + throw new Error('modelCatalog.ping not exercised in this test'); + }, + findByName: () => [], + listModels: async () => [], + listProviders: async () => [], + getProvider: async () => { + throw new Error('modelCatalog.getProvider not exercised in this test'); + }, + setDefaultModel: async () => { + throw new Error('modelCatalog.setDefaultModel not exercised in this test'); + }, + }; +} diff --git a/packages/agent-gateway/test/helpers/sharedServer.ts b/packages/agent-gateway/test/helpers/sharedServer.ts new file mode 100644 index 000000000..941bd6fe7 --- /dev/null +++ b/packages/agent-gateway/test/helpers/sharedServer.ts @@ -0,0 +1,34 @@ +import { inject } from 'vitest'; + +export interface SharedServerContext { + readonly base: string; + readonly token: string; +} + +declare module 'vitest' { + interface ProvidedContext { + readonly sharedServer: SharedServerContext; + } +} + +export function sharedServer(): SharedServerContext { + return inject('sharedServer'); +} + +export function sharedAuthHeaders(extra: Record = {}): Record { + return { ...extra, authorization: `Bearer ${sharedServer().token}` }; +} + +interface SharedFetchOptions { + readonly method?: string; + readonly headers?: Record; + readonly body?: string; + readonly signal?: AbortSignal; +} + +export async function sharedAuthedFetch(path: string, init: SharedFetchOptions = {}): Promise { + return fetch(`${sharedServer().base}${path}`, { + ...init, + headers: sharedAuthHeaders(init.headers), + } as never); +} diff --git a/packages/agent-gateway/test/openapi.test.ts b/packages/agent-gateway/test/openapi.test.ts index f9df03ef1..bd1b1cd86 100644 --- a/packages/agent-gateway/test/openapi.test.ts +++ b/packages/agent-gateway/test/openapi.test.ts @@ -2,45 +2,46 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; -import { type RunningServer, startServer } from '../src/start'; +import { startServer } from '../src/start'; import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; +import { sharedAuthHeaders, sharedServer } from './helpers/sharedServer'; describe('server-v2 OpenAPI', () => { - let server: RunningServer | undefined; - let home: string | undefined; - - afterEach(async () => { - if (server !== undefined) { - await server.close(); - server = undefined; - } - if (home !== undefined) { - await rm(home, { recursive: true, force: true }); - home = undefined; + async function fetchOpenApi(mcpManagement = false): Promise> { + if (!mcpManagement) { + const res = await fetch(`${sharedServer().base}/openapi.json`, { + headers: sharedAuthHeaders(), + } as never); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('application/json'); + return (await res.json()) as Record; } - }); - async function fetchOpenApi(mcpManagement = false): Promise> { - home = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-openapi-')); - server = await startServer({ + const home = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-openapi-')); + const server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent', env: { - PYTHINKER_CODE_EXPERIMENTAL_MCP_MANAGEMENT: mcpManagement ? '1' : '0', + PYTHINKER_CODE_EXPERIMENTAL_MCP_MANAGEMENT: '1', }, }); - const res = await fetch(`http://127.0.0.1:${server.port}/openapi.json`, { - headers: authHeaders(server), - } as never); - expect(res.status).toBe(200); - expect(res.headers.get('content-type')).toContain('application/json'); - return (await res.json()) as Record; + try { + const res = await fetch(`http://127.0.0.1:${server.port}/openapi.json`, { + headers: authHeaders(server), + } as never); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('application/json'); + return (await res.json()) as Record; + } finally { + await server.close(); + await rm(home, { recursive: true, force: true }); + } } it('returns a valid OpenAPI 3 document', async () => { diff --git a/packages/agent-gateway/test/questions.test.ts b/packages/agent-gateway/test/questions.test.ts index a6a27f9fd..4be55396a 100644 --- a/packages/agent-gateway/test/questions.test.ts +++ b/packages/agent-gateway/test/questions.test.ts @@ -9,7 +9,7 @@ import { type QuestionRequest, type QuestionResult, } from '@pymodel/agent-core-v2'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it, afterAll } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; @@ -69,7 +69,7 @@ describe('server-v2 /api/v1/sessions/{sid}/questions', () => { let home: string | undefined; let base: string; - beforeEach(async () => { + beforeAll(async () => { home = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-questions-')); server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, @@ -81,7 +81,7 @@ describe('server-v2 /api/v1/sessions/{sid}/questions', () => { base = `http://127.0.0.1:${server.port}`; }); - afterEach(async () => { + afterAll(async () => { if (server !== undefined) { await server.close(); server = undefined; diff --git a/packages/agent-gateway/test/rpc.test.ts b/packages/agent-gateway/test/rpc.test.ts index 3ab1eac3f..8cbc2e40f 100644 --- a/packages/agent-gateway/test/rpc.test.ts +++ b/packages/agent-gateway/test/rpc.test.ts @@ -31,7 +31,7 @@ import type { WorkspaceInstanceSnapshot, } from '@pymodel/agent-core-v2'; import { FakeRuntime } from '@pymodel/agent-core-v2/runtime/fakeRuntime'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it, afterAll } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; @@ -70,13 +70,13 @@ describe('server-v2 /api/v1/debug RPC', () => { let home: string | undefined; let base: string; - beforeEach(async () => { + beforeAll(async () => { home = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-rpc-')); server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent', debugEndpoints: true }); base = `http://127.0.0.1:${server.port}`; }); - afterEach(async () => { + afterAll(async () => { if (server !== undefined) { await server.close(); server = undefined; @@ -730,7 +730,7 @@ describe('server-v2 /api/v1/debug RPC auth', () => { let base: string; const token = 'test-secret-token'; - beforeEach(async () => { + beforeAll(async () => { home = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-rpc-auth-')); server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, @@ -743,7 +743,7 @@ describe('server-v2 /api/v1/debug RPC auth', () => { base = `http://127.0.0.1:${server.port}`; }); - afterEach(async () => { + afterAll(async () => { if (server !== undefined) { await server.close(); server = undefined; @@ -798,7 +798,7 @@ describe('server-v2 /api/v1/debug RPC (dev-only, whitelist-free)', () => { let home: string | undefined; let base: string; - beforeEach(async () => { + beforeAll(async () => { home = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-debug-rpc-')); server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, @@ -811,7 +811,7 @@ describe('server-v2 /api/v1/debug RPC (dev-only, whitelist-free)', () => { base = `http://127.0.0.1:${server.port}`; }); - afterEach(async () => { + afterAll(async () => { if (server !== undefined) { await server.close(); server = undefined; diff --git a/packages/agent-gateway/test/search/searchRoute.test.ts b/packages/agent-gateway/test/search/searchRoute.test.ts index a6af48d99..f6fdec66b 100644 --- a/packages/agent-gateway/test/search/searchRoute.test.ts +++ b/packages/agent-gateway/test/search/searchRoute.test.ts @@ -5,7 +5,7 @@ import { join } from 'node:path'; process.env['PYTHINKER_CODE_SEARCH_WORKER'] = '1'; import { ISessionIndex, type SessionSummary } from '@pymodel/agent-core-v2'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../../src/start'; import { TEST_HOST_IDENTITY } from '../helpers/hostIdentity'; @@ -63,7 +63,7 @@ describe('server-v2 /api/v1/search', () => { let home: string | undefined; let base: string; - beforeEach(async () => { + beforeAll(async () => { home = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-search-')); const sessionDir = join(home, 'sessions', WS, 's1', 'agents', 'main'); await mkdir(sessionDir, { recursive: true }); @@ -117,7 +117,7 @@ describe('server-v2 /api/v1/search', () => { base = `http://127.0.0.1:${server.port}`; }); - afterEach(async () => { + afterAll(async () => { if (server !== undefined) { await server.close(); server = undefined; diff --git a/packages/agent-gateway/test/search/searchService.bench.ts b/packages/agent-gateway/test/search/searchService.bench.ts new file mode 100644 index 000000000..fe3f5cf1c --- /dev/null +++ b/packages/agent-gateway/test/search/searchService.bench.ts @@ -0,0 +1,236 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { monitorEventLoopDelay, performance, type IntervalHistogram } from 'node:perf_hooks'; + +import type { + IBootstrapService, + IConfigService, + ILogService, + ISessionIndex, + SessionSummary, +} from '@pymodel/agent-core-v2'; +import { DATABASE_SECTION } from '@pymodel/agent-core-v2'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + GlobalSearchService, + drainGlobalSearchDisposals, +} from '../../src/search/searchService'; + +const WS = 'ws_test'; + +const T1 = 1_700_000_000_000; + +function summary(id: string, title: string, updatedAt = T1): SessionSummary { + return { id, workspaceId: WS, title, createdAt: updatedAt, updatedAt, archived: false }; +} + +function makeBootstrap(home: string): IBootstrapService { + return { + homeDir: home, + scope: (name: string) => name, + } as unknown as IBootstrapService; +} + +function makeSessionIndex(list: ISessionIndex['listRecent']): ISessionIndex { + return { + _serviceBrand: undefined, + prepare: async () => ({ state: 'uninitialized', degradedCount: 0 }), + status: () => ({ state: 'uninitialized', degradedCount: 0 }), + listRecent: list, + get: async () => undefined, + count: async () => 0, + remove: async () => {}, + }; +} + +function staticIndex(summaries: SessionSummary[]): ISessionIndex { + return makeSessionIndex(async () => ({ items: summaries, nextCursor: undefined })); +} + +function userLine(text: string, time: number, origin?: unknown): string { + return JSON.stringify({ + type: 'context.append_message', + time, + message: { + role: 'user', + content: [{ type: 'text', text }], + origin: origin ?? { kind: 'user' }, + }, + }); +} + +function assistantLine(text: string, time: number): string { + return JSON.stringify({ + type: 'context.append_loop_event', + time, + event: { type: 'content.part', part: { type: 'text', text } }, + }); +} + +async function writeWire( + home: string, + sessionId: string, + agentId: string, + lines: string[], +): Promise { + const dir = join(home, 'sessions', WS, sessionId, 'agents', agentId); + await mkdir(dir, { recursive: true }); + const file = join(dir, 'wire.jsonl'); + await writeFile(file, lines.map((l) => `${l}\n`).join(''), 'utf8'); + return file; +} + +const noopLog = { + error: () => {}, + warn: () => {}, + info: () => {}, + debug: () => {}, +} as unknown as ILogService; + +function makeConfig(searchEnabled: boolean): IConfigService { + return { + ready: Promise.resolve(), + get: (domain: string) => (domain === DATABASE_SECTION ? { search: searchEnabled } : undefined), + } as unknown as IConfigService; +} + +function makeService(home: string, index: ISessionIndex): GlobalSearchService { + const service = new GlobalSearchService(index, makeBootstrap(home), noopLog, makeConfig(true)); + service.syncDebounceMs = 0; + return service; +} + +describe('baseline: synthetic corpus', () => { + let home: string | undefined; + const services: GlobalSearchService[] = []; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'pythinker-kap-search-baseline-')); + }); + + afterEach(async () => { + for (const service of services.splice(0)) service.dispose(); + await drainGlobalSearchDisposals(); + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + const TOPICS = ['compaction', 'walrus', 'snapshot', 'recovery', '\u7D22\u5F15', '\u6301\u4E45\u5316']; + + async function writeCorpus(from: number, to: number): Promise { + const summaries: SessionSummary[] = []; + for (let i = from; i < to; i++) { + const id = `s${i}`; + summaries.push(summary(id, `session ${i} \u7D22\u5F15\u8BA8\u8BBA`, T1 + i)); + const lines: string[] = []; + for (let j = 0; j < 8; j++) { + lines.push(userLine(`session ${i} message ${j} about ${TOPICS[(i + j) % TOPICS.length]!}`, T1 + i * 100 + j)); + lines.push(assistantLine(`reply ${j} covering ${TOPICS[(i + 2 * j) % TOPICS.length]!}`, T1 + i * 100 + j + 1)); + } + await writeWire(home!, id, 'main', lines); + } + return summaries; + } + + async function medianMs(fn: () => Promise, runs = 5): Promise { + const times: number[] = []; + for (let r = 0; r < runs; r++) { + const t0 = performance.now(); + await fn(); + times.push(performance.now() - t0); + } + times.sort((a, b) => a - b); + return times[(times.length / 2) | 0]!; + } + + it('indexing and search latency scale within a linear budget from 100 to 400 sessions', async () => { + const all: SessionSummary[] = []; + const service = makeService(home!, staticIndex(all)); + services.push(service); + + all.push(...(await writeCorpus(0, 100))); + const t0 = performance.now(); + await service.reindex(); + const index100 = performance.now() - t0; + const terms100 = await medianMs(() => service.search({ query: 'compaction' })); + const literal100 = await medianMs(() => service.search({ query: 'message 3 about', mode: 'literal' })); + + all.push(...(await writeCorpus(100, 400))); + const t1 = performance.now(); + await service.reindex(); + const index400 = performance.now() - t1; + const terms400 = await medianMs(() => service.search({ query: 'compaction' })); + const literal400 = await medianMs(() => service.search({ query: 'message 3 about', mode: 'literal' })); + + const hits = await service.search({ query: 'compaction' }); + expect(hits.items.length).toBeGreaterThan(0); + expect((await service.search({ query: 'message 3 about', mode: 'literal' })).items.length).toBeGreaterThan(0); + + console.log( + `[baseline] searchService ${JSON.stringify({ + sessions: [100, 400], + reindexMs: [index100, index400], + termsMedianMs: [terms100, terms400], + literalMedianMs: [literal100, literal400], + })}`, + ); + expect(index400).toBeLessThan(index100 * 10 + 2000); + expect(terms400).toBeLessThan(terms100 * 10 + 100); + expect(literal400).toBeLessThan(literal100 * 10 + 100); + }, 120_000); + + it('stage-4: deep keyset pages cost like the first page, with a bounded event-loop pause', async () => { + const all: SessionSummary[] = []; + const service = makeService(home!, staticIndex(all)); + services.push(service); + all.push(...(await writeCorpus(0, 400))); + await service.reindex(); + + const eld: IntervalHistogram = monitorEventLoopDelay(); + eld.enable(); + try { + const tokens: (string | undefined)[] = [undefined]; + let page = await service.search({ query: 'message', sort: 'time_desc', pageSize: 20 }); + for (let p = 1; p < 10; p++) { + tokens.push(page.pageToken); + page = await service.search({ + query: 'message', + sort: 'time_desc', + pageSize: 20, + pageToken: page.pageToken, + }); + } + expect(page.items.length).toBe(20); + + const page1Ms = await medianMs(() => + service.search({ query: 'message', sort: 'time_desc', pageSize: 20 }), + ); + const page10Ms = await medianMs(() => + service.search({ query: 'message', sort: 'time_desc', pageSize: 20, pageToken: tokens[9] }), + ); + const literalMs = await medianMs(() => + service.search({ query: 'message 3 about', mode: 'literal' }), + ); + + const eldMaxMs = eld.max / 1e6; + const eldP99Ms = eld.percentile(99) / 1e6; + console.log( + `[baseline] stage4 ${JSON.stringify({ + sessions: 400, + page1MedianMs: page1Ms, + page10MedianMs: page10Ms, + literalMedianMs: literalMs, + eventLoopDelayMs: { p99: eldP99Ms, max: eldMaxMs }, + })}`, + ); + expect(page10Ms).toBeLessThan(page1Ms * 5 + 50); + expect(eldMaxMs).toBeLessThan(500); + } finally { + eld.disable(); + } + }, 120_000); +}); diff --git a/packages/agent-gateway/test/search/searchService.test.ts b/packages/agent-gateway/test/search/searchService.test.ts index f1480c7d2..798e0ba30 100644 --- a/packages/agent-gateway/test/search/searchService.test.ts +++ b/packages/agent-gateway/test/search/searchService.test.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; import { appendFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { monitorEventLoopDelay, performance, type IntervalHistogram } from 'node:perf_hooks'; +import { monitorEventLoopDelay, performance } from 'node:perf_hooks'; import { Worker } from 'node:worker_threads'; import type { @@ -2973,136 +2973,3 @@ describe('search lifecycle diagnostics (stage 5)', () => { expect(status.degraded).toContain('worker'); }); }); - -describe('baseline: synthetic corpus', () => { - let home: string | undefined; - const services: GlobalSearchService[] = []; - - beforeEach(async () => { - home = await mkdtemp(join(tmpdir(), 'pythinker-kap-search-baseline-')); - }); - - afterEach(async () => { - for (const service of services.splice(0)) service.dispose(); - await drainGlobalSearchDisposals(); - if (home !== undefined) { - await rm(home, { recursive: true, force: true }); - home = undefined; - } - }); - - const TOPICS = ['compaction', 'walrus', 'snapshot', 'recovery', '\u7D22\u5F15', '\u6301\u4E45\u5316']; - - async function writeCorpus(from: number, to: number): Promise { - const summaries: SessionSummary[] = []; - for (let i = from; i < to; i++) { - const id = `s${i}`; - summaries.push(summary(id, `session ${i} \u7D22\u5F15\u8BA8\u8BBA`, T1 + i)); - const lines: string[] = []; - for (let j = 0; j < 8; j++) { - lines.push(userLine(`session ${i} message ${j} about ${TOPICS[(i + j) % TOPICS.length]!}`, T1 + i * 100 + j)); - lines.push(assistantLine(`reply ${j} covering ${TOPICS[(i + 2 * j) % TOPICS.length]!}`, T1 + i * 100 + j + 1)); - } - await writeWire(home!, id, 'main', lines); - } - return summaries; - } - - async function medianMs(fn: () => Promise, runs = 5): Promise { - const times: number[] = []; - for (let r = 0; r < runs; r++) { - const t0 = performance.now(); - await fn(); - times.push(performance.now() - t0); - } - times.sort((a, b) => a - b); - return times[(times.length / 2) | 0]!; - } - - it('indexing and search latency scale within a linear budget from 100 to 400 sessions', async () => { - const all: SessionSummary[] = []; - const service = makeService(home!, staticIndex(all)); - services.push(service); - - all.push(...(await writeCorpus(0, 100))); - const t0 = performance.now(); - await service.reindex(); - const index100 = performance.now() - t0; - const terms100 = await medianMs(() => service.search({ query: 'compaction' })); - const literal100 = await medianMs(() => service.search({ query: 'message 3 about', mode: 'literal' })); - - all.push(...(await writeCorpus(100, 400))); - const t1 = performance.now(); - await service.reindex(); - const index400 = performance.now() - t1; - const terms400 = await medianMs(() => service.search({ query: 'compaction' })); - const literal400 = await medianMs(() => service.search({ query: 'message 3 about', mode: 'literal' })); - - const hits = await service.search({ query: 'compaction' }); - expect(hits.items.length).toBeGreaterThan(0); - expect((await service.search({ query: 'message 3 about', mode: 'literal' })).items.length).toBeGreaterThan(0); - - console.log( - `[baseline] searchService ${JSON.stringify({ - sessions: [100, 400], - reindexMs: [index100, index400], - termsMedianMs: [terms100, terms400], - literalMedianMs: [literal100, literal400], - })}`, - ); - expect(index400).toBeLessThan(index100 * 10 + 2000); - expect(terms400).toBeLessThan(terms100 * 10 + 100); - expect(literal400).toBeLessThan(literal100 * 10 + 100); - }, 120_000); - - it('stage-4: deep keyset pages cost like the first page, with a bounded event-loop pause', async () => { - const all: SessionSummary[] = []; - const service = makeService(home!, staticIndex(all)); - services.push(service); - all.push(...(await writeCorpus(0, 400))); - await service.reindex(); - - const eld: IntervalHistogram = monitorEventLoopDelay(); - eld.enable(); - try { - const tokens: (string | undefined)[] = [undefined]; - let page = await service.search({ query: 'message', sort: 'time_desc', pageSize: 20 }); - for (let p = 1; p < 10; p++) { - tokens.push(page.pageToken); - page = await service.search({ - query: 'message', - sort: 'time_desc', - pageSize: 20, - pageToken: page.pageToken, - }); - } - expect(page.items.length).toBe(20); - - const page1Ms = await medianMs(() => - service.search({ query: 'message', sort: 'time_desc', pageSize: 20 }), - ); - const page10Ms = await medianMs(() => - service.search({ query: 'message', sort: 'time_desc', pageSize: 20, pageToken: tokens[9] }), - ); - const literalMs = await medianMs(() => - service.search({ query: 'message 3 about', mode: 'literal' }), - ); - - const eldMaxMs = eld.max / 1e6; - const eldP99Ms = eld.percentile(99) / 1e6; - console.log( - `[baseline] stage4 ${JSON.stringify({ - sessions: 400, - page1MedianMs: page1Ms, - page10MedianMs: page10Ms, - literalMedianMs: literalMs, - eventLoopDelayMs: { p99: eldP99Ms, max: eldMaxMs }, - })}`, - ); - expect(page10Ms).toBeLessThan(page1Ms * 5 + 50); - expect(eldMaxMs).toBeLessThan(500); - } finally { - eld.disable(); - } - }, 120_000); -}); diff --git a/packages/agent-gateway/test/sessions.test.ts b/packages/agent-gateway/test/sessions.test.ts index 1c621574e..d0fb47de9 100644 --- a/packages/agent-gateway/test/sessions.test.ts +++ b/packages/agent-gateway/test/sessions.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { inflateRawSync } from 'node:zlib'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { AgentCron, @@ -78,10 +78,11 @@ function goalContinuationStarts(events: readonly Event2[]): readonly Event2 describe('server-v2 /api/v1/sessions', () => { let server: RunningServer | undefined; + let baselineServer: RunningServer | undefined; let home: string | undefined; let base: string; - beforeEach(async () => { + beforeAll(async () => { home = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-sessions-')); server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, @@ -91,12 +92,20 @@ describe('server-v2 /api/v1/sessions', () => { logLevel: 'silent', debugEndpoints: true, }); + baselineServer = server; base = `http://127.0.0.1:${server.port}`; }); afterEach(async () => { vi.restoreAllMocks(); vi.unstubAllEnvs(); + if (server !== baselineServer) { + await restartWithFreshHome(); + baselineServer = server; + } + }); + + afterAll(async () => { if (server !== undefined) { await server.close(); server = undefined; @@ -108,6 +117,27 @@ describe('server-v2 /api/v1/sessions', () => { } }); + async function restartWithFreshHome(): Promise { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await new Promise((resolve) => setTimeout(resolve, 25)); + await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as never); + } + home = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-sessions-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + debugEndpoints: true, + }); + base = `http://127.0.0.1:${server.port}`; + } + async function postJson( path: string, body?: unknown, @@ -359,10 +389,10 @@ describe('server-v2 /api/v1/sessions', () => { const { body } = await postJson('/api/v1/sessions', { metadata: { cwd: missing } }); expect(body.code).toBe(40409); - const workspaces = await getJson<{ items: unknown[] }>('/api/v1/workspaces'); - expect(workspaces.body.data.items).toEqual([]); + const workspaces = await getJson<{ items: { root: string }[] }>('/api/v1/workspaces'); + expect(workspaces.body.data.items.some((w) => w.root === missing)).toBe(false); const sessions = await getJson('/api/v1/sessions'); - expect(sessions.body.data.items).toEqual([]); + expect(sessions.body.data.items.some((s) => s.metadata.cwd === missing)).toBe(false); }); it('rejects create when metadata.cwd is not a directory (40409)', async () => { @@ -514,6 +544,7 @@ describe('server-v2 /api/v1/sessions', () => { }); it('paginates sessions with before_id and terminates on the last page', async () => { + await restartWithFreshHome(); const cwd = home as string; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const ids: string[] = []; @@ -984,18 +1015,18 @@ describe('server-v2 /api/v1/sessions', () => { ]); }); - it('cold-forks a session with hundreds of agents without materializing it', async () => { + it('cold-forks a session with hundreds of agents without materializing it', { timeout: 30_000 }, async () => { + await restartWithFreshHome(); const cwd = home as string; const parent = await postJson('/api/v1/sessions', { metadata: { cwd } }); expect(parent.body.code).toBe(0); const accessor = (server as RunningServer).core.accessor; - const parentSession = accessor.get(ISessionManager).list().at(0); - const workspace = (await accessor.get(IWorkspaceService).list()).at(0); + const parentWire = parent.body.data; + const parentId = parentWire.id; + const parentSession = accessor.get(ISessionManager).list().find((session) => session.id === parentId); + const workspace = (await accessor.get(IWorkspaceService).list()).find((item) => item.root === cwd); expect(parentSession).toBeDefined(); expect(workspace).toBeDefined(); - const parentId = parentSession!.id; - const parentWire = parent.body.data; - expect(parentWire.id).toBe(parentId); await closeSessionById(accessor, parentId); const sessionDir = sessionDirOf( @@ -1162,7 +1193,7 @@ describe('server-v2 /api/v1/sessions', () => { expect(resumed).toBeDefined(); expect(resumed!.accessor.get(ISessionContext).cwd).toBe(sourceWorkDir); expect(resumed!.accessor.get(IAgentLifecycleService).handleOf(MAIN_AGENT_ID)).toBeDefined(); - }, 30_000); + }); it('keeps cron tasks across a server restart through the wire', async () => { const cwd = home as string; @@ -1254,6 +1285,7 @@ describe('server-v2 /api/v1/sessions', () => { }); it('paginates archived_only without returning empty filtered pages', async () => { + await restartWithFreshHome(); const cwd = home as string; const archivedOlder = await postJson('/api/v1/sessions', { metadata: { cwd } }); await postJson<{ archived: boolean }>( @@ -1345,6 +1377,7 @@ describe('server-v2 /api/v1/sessions', () => { }); it('lists the union of legacy split buckets for one workspace, in recency order', async () => { + await restartWithFreshHome(); const typedRoot = 'C:\\Users\\Foo\\Proj'; const lowerRoot = 'c:\\users\\foo\\proj'; const typedId = encodeWorkDirKey(typedRoot); @@ -1573,6 +1606,7 @@ describe('server-v2 /api/v1/sessions', () => { }); it('derives the session title from the first prompt submitted via /api/v1', async () => { + await restartWithFreshHome(); const cwd = home as string; await writeFile(join(cwd, 'config.toml'), [ 'default_model = "stub"', '', '[providers.stub]', 'type = "openai"', @@ -1655,7 +1689,7 @@ describe('server-v2 /api/v1/sessions status context window', () => { let home: string | undefined; let base: string; - beforeEach(async () => { + beforeAll(async () => { home = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-status-')); await writeFile( join(home, 'config.toml'), @@ -1687,7 +1721,7 @@ describe('server-v2 /api/v1/sessions status context window', () => { base = `http://127.0.0.1:${server.port}`; }); - afterEach(async () => { + afterAll(async () => { if (server !== undefined) { await server.close(); server = undefined; @@ -1761,7 +1795,7 @@ describe('server-v2 /api/v1/sessions (minidb read model)', () => { '', ].join('\n'); - beforeEach(async () => { + beforeAll(async () => { process.env[READ_MODEL_ENV] = '1'; home = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-sessions-rm-')); await writeFile(join(home, 'config.toml'), READ_MODEL_CONFIG, 'utf8'); @@ -1776,7 +1810,7 @@ describe('server-v2 /api/v1/sessions (minidb read model)', () => { base = `http://127.0.0.1:${server.port}`; }); - afterEach(async () => { + afterAll(async () => { process.env[READ_MODEL_ENV] = 'false'; if (server !== undefined) { await server.close(); diff --git a/packages/agent-gateway/test/snapshot.test.ts b/packages/agent-gateway/test/snapshot.test.ts index ea88aeb56..0515d4b37 100644 --- a/packages/agent-gateway/test/snapshot.test.ts +++ b/packages/agent-gateway/test/snapshot.test.ts @@ -28,7 +28,7 @@ import { } from '@pymodel/agent-core-v2'; import { sessionSnapshotResponseSchema } from '../src/protocol/rest-snapshot'; import { emptySessionUsage } from '../src/protocol/session'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it, afterAll } from 'vitest'; import { registerSnapshotRoutes } from '../src/routes/snapshot'; import { type RunningServer, startServer } from '../src/start'; @@ -362,13 +362,13 @@ describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => { let home: string | undefined; let base: string; - beforeEach(async () => { + beforeAll(async () => { home = await mkdtemp(join(tmpdir(), 'pythinker-snapshot-test-')); server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); base = `http://127.0.0.1:${server.port}`; }); - afterEach(async () => { + afterAll(async () => { if (server !== undefined) { await server.close(); server = undefined; diff --git a/packages/agent-gateway/test/tasks.test.ts b/packages/agent-gateway/test/tasks.test.ts index 46bc247d2..1d48eb407 100644 --- a/packages/agent-gateway/test/tasks.test.ts +++ b/packages/agent-gateway/test/tasks.test.ts @@ -9,7 +9,7 @@ import { IModelCatalog, type AgentTask, } from '@pymodel/agent-core-v2'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; @@ -50,7 +50,7 @@ describe('server-v2 /api/v1/sessions/{sid}/tasks', () => { let home: string | undefined; let base: string; - beforeEach(async () => { + beforeAll(async () => { home = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-tasks-')); const modelCatalog: IModelCatalog = { _serviceBrand: undefined, @@ -87,7 +87,7 @@ describe('server-v2 /api/v1/sessions/{sid}/tasks', () => { base = `http://127.0.0.1:${server.port}`; }); - afterEach(async () => { + afterAll(async () => { if (server !== undefined) { await server.close(); server = undefined; diff --git a/packages/agent-gateway/test/tools.test.ts b/packages/agent-gateway/test/tools.test.ts index ebea55cf7..46e356184 100644 --- a/packages/agent-gateway/test/tools.test.ts +++ b/packages/agent-gateway/test/tools.test.ts @@ -14,7 +14,7 @@ import { listMcpServersResponseSchema, listToolsResponseSchema, } from '../src/protocol/rest-tool'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; @@ -41,7 +41,7 @@ describe('server-v2 /api/v1 tools + mcp', () => { let home: string | undefined; let base: string; - beforeEach(async () => { + beforeAll(async () => { home = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-tools-')); const modelCatalog: IModelCatalog = { _serviceBrand: undefined, @@ -78,7 +78,7 @@ describe('server-v2 /api/v1 tools + mcp', () => { base = `http://127.0.0.1:${server.port}`; }); - afterEach(async () => { + afterAll(async () => { if (server !== undefined) { await server.close(); server = undefined; diff --git a/packages/agent-gateway/test/workspaceLayout.test.ts b/packages/agent-gateway/test/workspaceLayout.test.ts index 9baad80c4..34e26ee3d 100644 --- a/packages/agent-gateway/test/workspaceLayout.test.ts +++ b/packages/agent-gateway/test/workspaceLayout.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it, afterAll } from 'vitest'; import { IAgentLifecycleService, @@ -27,7 +27,7 @@ describe('local/local on-disk layout (byte compatibility)', () => { let base: string; const homes: string[] = []; - beforeEach(async () => { + beforeAll(async () => { home = await mkdtemp(join(tmpdir(), 'pythinker-layout-home-')); workDir = await mkdtemp(join(tmpdir(), 'pythinker-layout-work-')); homes.push(home, workDir); @@ -42,7 +42,7 @@ describe('local/local on-disk layout (byte compatibility)', () => { base = `http://127.0.0.1:${server.port}`; }); - afterEach(async () => { + afterAll(async () => { if (server !== undefined) { await server.close(); server = undefined; diff --git a/packages/agent-gateway/test/wsBearerProtocol.test.ts b/packages/agent-gateway/test/wsBearerProtocol.test.ts index e881c1dba..83bbcaf00 100644 --- a/packages/agent-gateway/test/wsBearerProtocol.test.ts +++ b/packages/agent-gateway/test/wsBearerProtocol.test.ts @@ -1,13 +1,8 @@ -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import WebSocket from 'ws'; -import { type RunningServer, startServer } from '../src/start'; -import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { WS_BEARER_PROTOCOL_PREFIX } from '../src/transport/ws/bearerProtocol'; +import { sharedServer } from './helpers/sharedServer'; function openWs(url: string, protocols: string | string[]): Promise { return new Promise((resolve, reject) => { @@ -18,39 +13,24 @@ function openWs(url: string, protocols: string | string[]): Promise { } describe('server-v2 WS bearer subprotocol', () => { - let server: RunningServer | undefined; - let home: string | undefined; - let wsUrl: string; const sockets: WebSocket[] = []; - beforeEach(async () => { - home = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-ws-bearer-')); - server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); - wsUrl = `ws://127.0.0.1:${server.port}/api/v1/ws`; - }); - - afterEach(async () => { + afterEach(() => { for (const ws of sockets.splice(0)) { ws.close(); } - if (server !== undefined) { - await server.close(); - server = undefined; - } - if (home !== undefined) { - await rm(home, { recursive: true, force: true }); - home = undefined; - } }); it('accepts a valid bearer subprotocol', async () => { - const token = server?.authTokenService.getToken() ?? ''; + const token = sharedServer().token; + const wsUrl = `${sharedServer().base.replace(/^http/, 'ws')}/api/v1/ws`; const ws = await openWs(wsUrl, `${WS_BEARER_PROTOCOL_PREFIX}${token}`); sockets.push(ws); expect(ws.protocol).toBe(`${WS_BEARER_PROTOCOL_PREFIX}${token}`); }); it('rejects an invalid bearer subprotocol', async () => { + const wsUrl = `${sharedServer().base.replace(/^http/, 'ws')}/api/v1/ws`; await expect(openWs(wsUrl, `${WS_BEARER_PROTOCOL_PREFIX}wrong-token`)).rejects.toThrow(); }); }); diff --git a/packages/agent-gateway/test/wsUpgradeAuth.test.ts b/packages/agent-gateway/test/wsUpgradeAuth.test.ts index b79e8f711..266feb4b4 100644 --- a/packages/agent-gateway/test/wsUpgradeAuth.test.ts +++ b/packages/agent-gateway/test/wsUpgradeAuth.test.ts @@ -1,15 +1,7 @@ -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { WebSocket, type RawData } from 'ws'; -import { type RunningServer, startServer } from '../src/start'; -import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; -import { fixedTokenAuth } from './helpers/fixedAuth'; - -const TOKEN = 'test-token'; +import { sharedServer } from './helpers/sharedServer'; function rawToString(data: RawData): string { if (typeof data === 'string') return data; @@ -61,57 +53,41 @@ function expectRejected(url: string, opts?: ConnectOptions): Promise { } describe('WS upgrade auth', () => { - let server: RunningServer | undefined; - let home: string | undefined; - let v1Url: string; const sockets: WebSocket[] = []; - beforeEach(async () => { - home = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-ws-upgrade-auth-')); - server = await startServer({ - hostIdentity: TEST_HOST_IDENTITY, - host: '127.0.0.1', - port: 0, - homeDir: home, - logLevel: 'silent', - authTokenService: fixedTokenAuth(TOKEN), - }); - v1Url = `ws://127.0.0.1:${server.port}/api/v1/ws`; - }); - - afterEach(async () => { + afterEach(() => { for (const ws of sockets.splice(0)) { try { ws.close(); } catch { } } - if (server !== undefined) { - await server.close(); - server = undefined; - } - if (home !== undefined) { - await rm(home, { recursive: true, force: true }); - home = undefined; - } }); + function v1Url(): string { + return `${sharedServer().base.replace(/^http/, 'ws')}/api/v1/ws`; + } + + function token(): string { + return sharedServer().token; + } + describe('/api/v1/ws', () => { const firstType = 'server_hello'; - const url = (): string => v1Url; + const url = (): string => v1Url(); it('accepts a valid bearer subprotocol and echoes it', async () => { const { ws, firstFrame } = await openConn(url(), { - protocols: [`pythinker-code.bearer.${TOKEN}`], + protocols: [`pythinker-code.bearer.${token()}`], }); sockets.push(ws); - expect(ws.protocol).toBe(`pythinker-code.bearer.${TOKEN}`); + expect(ws.protocol).toBe(`pythinker-code.bearer.${token()}`); expect(firstFrame).toMatchObject({ type: firstType }); }); it('accepts a valid Authorization bearer header', async () => { const { ws, firstFrame } = await openConn(url(), { - headers: { Authorization: `Bearer ${TOKEN}` }, + headers: { Authorization: `Bearer ${token()}` }, }); sockets.push(ws); expect(firstFrame).toMatchObject({ type: firstType }); @@ -127,7 +103,7 @@ describe('WS upgrade auth', () => { }); it('rejects upgrades to a non-WS path', async () => { - const badUrl = `ws://127.0.0.1:${(server as RunningServer).port}/api/v1/other`; - await expectRejected(badUrl, { protocols: [`pythinker-code.bearer.${TOKEN}`] }); + const badUrl = `${v1Url().replace('/api/v1/ws', '/api/v1/other')}`; + await expectRejected(badUrl, { protocols: [`pythinker-code.bearer.${token()}`] }); }); }); diff --git a/packages/agent-gateway/test/wsV1Resync.test.ts b/packages/agent-gateway/test/wsV1Resync.test.ts index 55b0c6a1c..460824be9 100644 --- a/packages/agent-gateway/test/wsV1Resync.test.ts +++ b/packages/agent-gateway/test/wsV1Resync.test.ts @@ -8,7 +8,7 @@ import { IAgentLifecycleService, getLiveSessionById, } from '@pymodel/agent-core-v2'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it, afterAll } from 'vitest'; import { WebSocket } from 'ws'; import { type RunningServer, startServer } from '../src/start'; @@ -105,14 +105,14 @@ describe('server-v2 /api/v1/ws resync', () => { let base: string; let wsUrl: string; - beforeEach(async () => { + beforeAll(async () => { home = await mkdtemp(join(tmpdir(), 'pythinker-wsv1-test-')); server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); base = `http://127.0.0.1:${server.port}`; wsUrl = `ws://127.0.0.1:${server.port}/api/v1/ws`; }); - afterEach(async () => { + afterAll(async () => { if (server !== undefined) { await server.close(); server = undefined; diff --git a/packages/agent-gateway/vitest.bench.config.ts b/packages/agent-gateway/vitest.bench.config.ts new file mode 100644 index 000000000..3626e19e6 --- /dev/null +++ b/packages/agent-gateway/vitest.bench.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +import { rawTextPlugin } from '../../build/raw-text-plugin.mjs'; + +export default defineConfig({ + plugins: [rawTextPlugin()], + test: { + name: 'agent-gateway-bench', + include: ['test/**/*.bench.ts'], + setupFiles: ['test/setup.ts'], + }, +}); diff --git a/packages/agent-gateway/vitest.config.ts b/packages/agent-gateway/vitest.config.ts index 2fab5a1fe..11f057b7b 100644 --- a/packages/agent-gateway/vitest.config.ts +++ b/packages/agent-gateway/vitest.config.ts @@ -10,5 +10,7 @@ export default defineConfig({ name: 'agent-gateway', include: ['test/**/*.{test,e2e}.ts'], setupFiles: ['test/setup.ts'], + globalSetup: ['test/globalSetup.ts'], + testTimeout: 15_000, }, });