f9447f8e5f
CI / typecheck · build · test · bundle-size (push) Failing after 1s
CI / clean-room dependency closure smoke (push) Failing after 1s
CI / server-runtime e2e (docker · pg + valkey) (push) Failing after 2s
Deploy Install Scripts / deploy (push) Failing after 2s
Windows / build (push) Has been cancelled
303 lines
11 KiB
JavaScript
303 lines
11 KiB
JavaScript
// Phase 10 — Docker E2E driver for server-beta. Verifies the
|
|
// runtime-relevant slice of the API actually shipped in the Postgres routes:
|
|
//
|
|
// - GET /healthz — server is alive
|
|
// - GET /api/readiness — Postgres bootstrap completed
|
|
// - GET /api/health — BullMQ queue engine is bullmq + redis ok
|
|
// - POST /v1/sessions/start, /v1/sessions/:id/end
|
|
// - POST /v1/events?wait=true (returns generationJob descriptor)
|
|
// - GET /v1/events/:id — read-back via team scope
|
|
// - GET /v1/jobs/:id — generation job status
|
|
// - 401/403 paths for missing/invalid/revoked keys
|
|
|
|
import net from 'node:net';
|
|
|
|
const baseUrl = process.env.E2E_BASE_URL ?? 'http://claude-mem-server:37877';
|
|
const redisHost = process.env.E2E_REDIS_HOST ?? 'valkey';
|
|
const redisPort = Number.parseInt(process.env.E2E_REDIS_PORT ?? '6379', 10);
|
|
const phase = process.env.E2E_PHASE ?? 'phase1';
|
|
const apiKey = requiredEnv('E2E_API_KEY');
|
|
const readOnlyKey = process.env.E2E_READ_ONLY_API_KEY ?? '';
|
|
const revokedKey = process.env.E2E_REVOKED_API_KEY ?? '';
|
|
const runId = process.env.E2E_RUN_ID ?? `e2e-${Date.now()}`;
|
|
const projectIdFromEnv = process.env.E2E_PROJECT_ID ?? '';
|
|
|
|
function requiredEnv(key) {
|
|
const value = process.env[key];
|
|
if (!value) {
|
|
throw new Error(`${key} is required`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function assert(condition, message) {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
|
|
async function sleep(ms) {
|
|
await new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function request(path, options = {}) {
|
|
const headers = {
|
|
...(options.json !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
...(options.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {}),
|
|
...(options.headers ?? {}),
|
|
};
|
|
return fetch(`${baseUrl}${path}`, {
|
|
method: options.method ?? (options.json === undefined ? 'GET' : 'POST'),
|
|
headers,
|
|
body: options.json === undefined ? undefined : JSON.stringify(options.json),
|
|
});
|
|
}
|
|
|
|
async function json(response) {
|
|
const text = await response.text();
|
|
try {
|
|
return text ? JSON.parse(text) : null;
|
|
} catch (error) {
|
|
throw new Error(`Invalid JSON response (${response.status}): ${text}\n${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
async function requestJson(path, options = {}) {
|
|
const response = await request(path, options);
|
|
const body = await json(response);
|
|
return { response, body };
|
|
}
|
|
|
|
async function expectStatus(path, status, options = {}) {
|
|
const response = await request(path, options);
|
|
assert(response.status === status, `${path} expected HTTP ${status}, got ${response.status}: ${await response.text()}`);
|
|
}
|
|
|
|
async function waitForReadiness() {
|
|
const deadline = Date.now() + 120_000;
|
|
let lastError = '';
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const health = await request('/healthz');
|
|
const readiness = await request('/api/readiness');
|
|
if (health.ok && readiness.ok) {
|
|
return;
|
|
}
|
|
lastError = `health=${health.status} readiness=${readiness.status}`;
|
|
} catch (error) {
|
|
lastError = error instanceof Error ? error.message : String(error);
|
|
}
|
|
await sleep(1000);
|
|
}
|
|
throw new Error(`Server did not become ready: ${lastError}`);
|
|
}
|
|
|
|
async function assertRedisPing() {
|
|
const result = await new Promise((resolve, reject) => {
|
|
const socket = net.createConnection({ host: redisHost, port: redisPort });
|
|
socket.setTimeout(3000);
|
|
let data = '';
|
|
socket.on('connect', () => socket.write('*1\r\n$4\r\nPING\r\n'));
|
|
socket.on('data', chunk => {
|
|
data += chunk.toString('utf8');
|
|
if (data.includes('PONG')) {
|
|
socket.end();
|
|
resolve(data);
|
|
}
|
|
});
|
|
socket.on('timeout', () => {
|
|
socket.destroy();
|
|
reject(new Error('Redis PING timed out'));
|
|
});
|
|
socket.on('error', reject);
|
|
socket.on('close', () => {
|
|
if (!data.includes('PONG')) {
|
|
reject(new Error(`Redis PING failed: ${data}`));
|
|
}
|
|
});
|
|
});
|
|
assert(String(result).includes('PONG'), `Redis did not return PONG: ${result}`);
|
|
}
|
|
|
|
async function assertQueueHealth() {
|
|
const { response, body } = await requestJson('/api/health');
|
|
assert(response.ok, `/api/health expected OK, got ${response.status}`);
|
|
assert(body.queue?.engine === 'bullmq', `expected BullMQ queue engine, got ${JSON.stringify(body.queue)}`);
|
|
assert(body.queue?.redis?.status === 'ok', `expected Redis health ok, got ${JSON.stringify(body.queue?.redis)}`);
|
|
}
|
|
|
|
async function assertInfoEndpoint() {
|
|
const { response, body } = await requestJson('/v1/info');
|
|
assert(response.ok, `/v1/info expected OK, got ${response.status}`);
|
|
assert(body.runtime === 'server-beta', `expected runtime=server-beta, got ${body.runtime}`);
|
|
assert(body.postgres?.initialized === true, `expected postgres.initialized=true, got ${JSON.stringify(body.postgres)}`);
|
|
assert(body.boundaries?.queueManager?.status === 'active', `expected queue manager active, got ${JSON.stringify(body.boundaries?.queueManager)}`);
|
|
}
|
|
|
|
async function phase1() {
|
|
console.log(`[e2e] phase1 starting (${runId})`);
|
|
await waitForReadiness();
|
|
await assertQueueHealth();
|
|
await assertInfoEndpoint();
|
|
await assertRedisPing();
|
|
|
|
// Auth — missing key returns 401, invalid key returns 403. Auth runs
|
|
// before body validation, so the body content is irrelevant here.
|
|
await expectStatus('/v1/sessions/start', 401, {
|
|
method: 'POST',
|
|
json: { projectId: projectIdFromEnv, contentSessionId: 'unauth' },
|
|
});
|
|
await expectStatus('/v1/sessions/start', 403, {
|
|
method: 'POST',
|
|
apiKey: 'cmem_invalid_key_for_e2e',
|
|
json: { projectId: projectIdFromEnv, contentSessionId: 'invalid' },
|
|
});
|
|
|
|
// Read-only key cannot write.
|
|
if (readOnlyKey) {
|
|
await expectStatus('/v1/sessions/start', 403, {
|
|
method: 'POST',
|
|
apiKey: readOnlyKey,
|
|
json: { projectId: projectIdFromEnv, contentSessionId: `readonly-${runId}` },
|
|
});
|
|
}
|
|
|
|
// Open a session. projectId is required in the body and must match the
|
|
// project the api-key is scoped to (passed in via E2E_PROJECT_ID).
|
|
assert(projectIdFromEnv, 'E2E_PROJECT_ID is required for phase1');
|
|
const sessionRes = await requestJson('/v1/sessions/start', {
|
|
apiKey,
|
|
json: {
|
|
projectId: projectIdFromEnv,
|
|
contentSessionId: `content-${runId}`,
|
|
platformSource: 'docker-e2e',
|
|
},
|
|
});
|
|
assert(sessionRes.response.status === 201, `session create failed: ${sessionRes.response.status} ${JSON.stringify(sessionRes.body)}`);
|
|
const session = sessionRes.body.session;
|
|
assert(session?.id, `session response missing id: ${JSON.stringify(sessionRes.body)}`);
|
|
const projectId = session.projectId;
|
|
assert(projectId, `session missing projectId: ${JSON.stringify(session)}`);
|
|
|
|
// POST /v1/events?wait=true — returns a generationJob descriptor on
|
|
// success. This is the Phase 10 contract: HTTP path returns the queued
|
|
// job, and the worker process generates the observation later.
|
|
const createdEvent = await requestJson('/v1/events?wait=true', {
|
|
apiKey,
|
|
json: {
|
|
projectId,
|
|
serverSessionId: session.id,
|
|
sourceType: 'api',
|
|
eventType: 'observation.created',
|
|
contentSessionId: `content-${runId}`,
|
|
memorySessionId: `memory-${runId}`,
|
|
payload: { tool_name: 'Read', runId },
|
|
occurredAtEpoch: Date.now(),
|
|
},
|
|
});
|
|
assert(
|
|
createdEvent.response.status === 201,
|
|
`event create failed: ${createdEvent.response.status} ${JSON.stringify(createdEvent.body)}`,
|
|
);
|
|
const event = createdEvent.body.event;
|
|
assert(event?.id, `event response missing id: ${JSON.stringify(createdEvent.body)}`);
|
|
// wait=true MUST include a generationJob descriptor (queued or generated).
|
|
// Its absence indicates the queue path was bypassed.
|
|
assert(
|
|
createdEvent.body.generationJob !== undefined && createdEvent.body.generationJob !== null,
|
|
`wait=true response missing generationJob: ${JSON.stringify(createdEvent.body)}`,
|
|
);
|
|
|
|
// Read-back through the team-scoped GET /v1/events/:id route.
|
|
const fetched = await requestJson(`/v1/events/${event.id}`, { apiKey });
|
|
assert(fetched.response.ok, `event fetch failed: ${fetched.response.status} ${JSON.stringify(fetched.body)}`);
|
|
|
|
// Poll the generation job — it MUST exist in Postgres regardless of
|
|
// whether a provider is configured. Without a provider, status stays at
|
|
// `queued`; with one, it eventually becomes `generated`. Either way the
|
|
// job row is observable via GET /v1/jobs/:id.
|
|
const jobId = createdEvent.body.generationJob.id;
|
|
if (jobId) {
|
|
const jobRes = await requestJson(`/v1/jobs/${jobId}`, { apiKey });
|
|
assert(jobRes.response.ok, `job fetch failed: ${jobRes.response.status} ${JSON.stringify(jobRes.body)}`);
|
|
}
|
|
|
|
// Close the session.
|
|
const ended = await requestJson(`/v1/sessions/${session.id}/end`, {
|
|
method: 'POST',
|
|
apiKey,
|
|
json: {},
|
|
});
|
|
assert(ended.response.ok, `session end failed: ${ended.response.status} ${JSON.stringify(ended.body)}`);
|
|
|
|
console.log(`[e2e] phase1 passed session=${session.id} event=${event.id} job=${jobId ?? 'none'}`);
|
|
}
|
|
|
|
async function phase2() {
|
|
console.log(`[e2e] phase2 after restart starting (${runId})`);
|
|
await waitForReadiness();
|
|
await assertQueueHealth();
|
|
await assertInfoEndpoint();
|
|
await assertRedisPing();
|
|
|
|
// Revoked key MUST fail on every authenticated route. The restart between
|
|
// phase1 and phase2 specifically asserts the revocation lives in Postgres,
|
|
// not an in-memory cache.
|
|
if (revokedKey) {
|
|
await expectStatus('/v1/sessions/start', 403, {
|
|
method: 'POST',
|
|
apiKey: revokedKey,
|
|
json: { projectId: projectIdFromEnv, contentSessionId: `revoked-${runId}` },
|
|
});
|
|
}
|
|
|
|
// Full key still works after restart — durable session creation + event
|
|
// ingest path through Postgres.
|
|
assert(projectIdFromEnv, 'E2E_PROJECT_ID is required for phase2');
|
|
const sessionRes = await requestJson('/v1/sessions/start', {
|
|
apiKey,
|
|
json: {
|
|
projectId: projectIdFromEnv,
|
|
contentSessionId: `content-after-restart-${runId}`,
|
|
platformSource: 'docker-e2e',
|
|
},
|
|
});
|
|
assert(
|
|
sessionRes.response.status === 201,
|
|
`session create after restart failed: ${sessionRes.response.status} ${JSON.stringify(sessionRes.body)}`,
|
|
);
|
|
const session = sessionRes.body.session;
|
|
const projectId = session.projectId;
|
|
|
|
const createdEvent = await requestJson('/v1/events?wait=true', {
|
|
apiKey,
|
|
json: {
|
|
projectId,
|
|
serverSessionId: session.id,
|
|
sourceType: 'api',
|
|
eventType: 'observation.created',
|
|
contentSessionId: `content-after-restart-${runId}`,
|
|
payload: { tool_name: 'Edit', runId, after: 'restart' },
|
|
occurredAtEpoch: Date.now(),
|
|
},
|
|
});
|
|
assert(
|
|
createdEvent.response.status === 201,
|
|
`event after restart failed: ${createdEvent.response.status} ${JSON.stringify(createdEvent.body)}`,
|
|
);
|
|
assert(
|
|
createdEvent.body.generationJob !== undefined && createdEvent.body.generationJob !== null,
|
|
`wait=true after restart missing generationJob: ${JSON.stringify(createdEvent.body)}`,
|
|
);
|
|
|
|
console.log(`[e2e] phase2 passed session=${session.id} event=${createdEvent.body.event.id}`);
|
|
}
|
|
|
|
if (phase === 'phase1') {
|
|
await phase1();
|
|
} else if (phase === 'phase2') {
|
|
await phase2();
|
|
} else {
|
|
throw new Error(`Unknown E2E_PHASE: ${phase}`);
|
|
}
|