Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:07:03 +08:00

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}`);
}