openreader/compute/worker/tests/fixtures/fake-control-plane.ts
Richard R e1483ab80a refactor(worker): modularize worker entrypoint and add vitest infra
move worker startup logic from server.ts to runtime.ts for better modularity
add worker-loop-policy.ts for loop control abstraction
delete legacy unit tests under tests/unit/ related to compute worker and control-plane
add vitest config and test directories for compute-core and compute-worker
add vitest workflow for CI
update package.json scripts for new test commands and add vitest as dev dependency

BREAKING CHANGE: worker entrypoint is now compute/worker/src/runtime.ts instead of server.ts; legacy Playwright-based unit tests for compute worker are removed in favor of Vitest
2026-05-30 11:00:01 -06:00

119 lines
3.8 KiB
TypeScript

import type {
PdfLayoutJobResult,
WhisperAlignJobResult,
WorkerOperationEvent,
WorkerOperationRequest,
WorkerOperationState,
} from '@openreader/compute-core/api-contracts';
import type { ComputeWorkerRouteDeps } from '../../src/runtime';
type ComputeResult = WhisperAlignJobResult | PdfLayoutJobResult;
type ComputeState = WorkerOperationState<ComputeResult>;
type ComputeEvent = WorkerOperationEvent<ComputeResult>;
export class FakeControlPlane {
private readonly stateByOpId = new Map<string, ComputeState>();
private readonly opIdByOpKey = new Map<string, string>();
private readonly eventsByOpId = new Map<string, ComputeEvent[]>();
private nextOpId = 1;
readonly deps: ComputeWorkerRouteDeps = {
orchestrator: {
enqueueOrReuse: async (request) => this.enqueueOrReuse(request),
markRunning: async (input) => this.updateState(input.opId, {
status: 'running',
startedAt: input.startedAt,
updatedAt: input.updatedAt,
timing: input.timing,
}),
markProgress: async (input) => this.updateState(input.opId, {
status: 'running',
progress: input.progress,
updatedAt: input.updatedAt,
timing: input.timing,
}),
markSucceeded: async (input) => this.updateState(input.opId, {
status: 'succeeded',
result: input.result as ComputeResult,
updatedAt: input.updatedAt,
timing: input.timing,
}),
markFailed: async (input) => this.updateState(input.opId, {
status: 'failed',
error: typeof input.error === 'string' ? { message: input.error } : input.error,
updatedAt: input.updatedAt,
timing: input.timing,
}),
},
operationStateStore: {
getOpState: async (opId) => this.stateByOpId.get(opId) ?? null,
},
operationEventStream: {
subscribe: async ({ opId, sinceEventId, onEvent }) => {
const since = Math.max(0, Math.floor(sinceEventId ?? 0));
const replay = (this.eventsByOpId.get(opId) ?? []).filter((event) => event.eventId > since);
for (const event of replay) {
await onEvent(event);
}
return () => undefined;
},
},
};
seedState(state: ComputeState): void {
this.stateByOpId.set(state.opId, state);
this.opIdByOpKey.set(state.opKey, state.opId);
}
seedEvent(opId: string, event: ComputeEvent): void {
const list = this.eventsByOpId.get(opId) ?? [];
list.push(event);
this.eventsByOpId.set(opId, list);
}
private async enqueueOrReuse(request: WorkerOperationRequest): Promise<ComputeState> {
const existingId = this.opIdByOpKey.get(request.opKey);
if (existingId) {
const existing = this.stateByOpId.get(existingId);
if (existing) return existing;
}
const now = Date.now();
const opId = `op-${this.nextOpId++}`;
const state: ComputeState = {
opId,
opKey: request.opKey,
kind: request.kind,
jobId: `job-${opId}`,
status: 'queued',
queuedAt: now,
updatedAt: now,
};
this.stateByOpId.set(opId, state);
this.opIdByOpKey.set(request.opKey, opId);
this.seedEvent(opId, { eventId: 1, snapshot: state });
return state;
}
private async updateState(
opId: string,
patch: Partial<ComputeState>,
): Promise<ComputeState> {
const current = this.stateByOpId.get(opId);
if (!current) {
throw new Error(`Unknown opId: ${opId}`);
}
const next: ComputeState = {
...current,
...patch,
updatedAt: patch.updatedAt ?? Date.now(),
};
this.stateByOpId.set(opId, next);
const currentEvents = this.eventsByOpId.get(opId) ?? [];
const nextEventId = (currentEvents.at(-1)?.eventId ?? 0) + 1;
currentEvents.push({ eventId: nextEventId, snapshot: next });
this.eventsByOpId.set(opId, currentEvents);
return next;
}
}