feat: complete TASK-WP0-02 contract baseline
This commit is contained in:
@@ -9,6 +9,7 @@
|
|||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dada/shared-contracts": "workspace:*",
|
||||||
"@fastify/swagger": "9.8.1",
|
"@fastify/swagger": "9.8.1",
|
||||||
"@sinclair/typebox": "0.34.52",
|
"@sinclair/typebox": "0.34.52",
|
||||||
"better-sqlite3": "13.0.1",
|
"better-sqlite3": "13.0.1",
|
||||||
|
|||||||
+126
-3
@@ -1,17 +1,140 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BootstrapResponseSchema,
|
||||||
|
CorrelationIdSchema,
|
||||||
|
ErrorDetailsSchema,
|
||||||
|
ErrorEnvelopeSchema,
|
||||||
|
GenerationErrorCategorySchema,
|
||||||
|
ModelConfigSseEventSchema,
|
||||||
|
ModelRuntimeSseEventSchema,
|
||||||
|
SseEventSchema,
|
||||||
|
StableEngineeringErrorCodeSchema,
|
||||||
|
StateSseEventSchema,
|
||||||
|
isCorrelationId,
|
||||||
|
type BootstrapResponse,
|
||||||
|
} from "@dada/shared-contracts";
|
||||||
import swagger from "@fastify/swagger";
|
import swagger from "@fastify/swagger";
|
||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
|
|
||||||
export function createApp() {
|
import { EventHub } from "./event-hub.js";
|
||||||
const app = Fastify({ logger: false });
|
|
||||||
|
|
||||||
void app.register(swagger, {
|
const defaultBootstrap: BootstrapResponse = {
|
||||||
|
app_version: "0.0.0",
|
||||||
|
dependencies: [],
|
||||||
|
model_summary: {
|
||||||
|
config_set_version: null,
|
||||||
|
configured_default_model_id: null,
|
||||||
|
recommended_model_id: null,
|
||||||
|
runtime_availability_version: null,
|
||||||
|
},
|
||||||
|
public_features: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CreateAppOptions {
|
||||||
|
bootstrap?: () => BootstrapResponse | Promise<BootstrapResponse>;
|
||||||
|
eventHub?: EventHub;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestCorrelationId(headers: Record<string, string | string[] | undefined>) {
|
||||||
|
const header = headers["x-correlation-id"];
|
||||||
|
const candidate = Array.isArray(header) ? header[0] : header;
|
||||||
|
return isCorrelationId(candidate) ? candidate : randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createApp(options: CreateAppOptions = {}) {
|
||||||
|
const eventHub = options.eventHub ?? new EventHub();
|
||||||
|
const bootstrap = options.bootstrap ?? (() => defaultBootstrap);
|
||||||
|
const app = Fastify({
|
||||||
|
genReqId: (request) => requestCorrelationId(request.headers),
|
||||||
|
logger: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(swagger, {
|
||||||
openapi: {
|
openapi: {
|
||||||
info: {
|
info: {
|
||||||
title: "Dada P0-A",
|
title: "Dada P0-A",
|
||||||
version: "0.0.0",
|
version: "0.0.0",
|
||||||
},
|
},
|
||||||
|
openapi: "3.1.0",
|
||||||
|
},
|
||||||
|
refResolver: {
|
||||||
|
buildLocalReference: (schema, _baseUri, _fragment, index) =>
|
||||||
|
typeof schema.$id === "string" ? schema.$id : `schema-${index}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const schema of [
|
||||||
|
CorrelationIdSchema,
|
||||||
|
GenerationErrorCategorySchema,
|
||||||
|
StableEngineeringErrorCodeSchema,
|
||||||
|
ErrorDetailsSchema,
|
||||||
|
ErrorEnvelopeSchema,
|
||||||
|
BootstrapResponseSchema,
|
||||||
|
StateSseEventSchema,
|
||||||
|
ModelConfigSseEventSchema,
|
||||||
|
ModelRuntimeSseEventSchema,
|
||||||
|
SseEventSchema,
|
||||||
|
]) {
|
||||||
|
app.addSchema(schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.addHook("onRequest", (request, reply, done) => {
|
||||||
|
reply.header("X-Correlation-Id", request.id);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
"/api/v1/bootstrap",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
operationId: "getBootstrap",
|
||||||
|
response: {
|
||||||
|
200: BootstrapResponseSchema,
|
||||||
|
},
|
||||||
|
tags: ["Bootstrap"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async () => bootstrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
"/api/v1/events",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
operationId: "getEvents",
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
content: {
|
||||||
|
"text/event-stream": {
|
||||||
|
schema: SseEventSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description: "Non-sensitive state change hints. REST remains authoritative.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tags: ["State events"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(request, reply) => {
|
||||||
|
reply.hijack();
|
||||||
|
reply.raw.setHeader("Cache-Control", "no-store");
|
||||||
|
reply.raw.setHeader("Connection", "keep-alive");
|
||||||
|
reply.raw.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
||||||
|
reply.raw.setHeader("X-Correlation-Id", request.id);
|
||||||
|
reply.raw.writeHead(200);
|
||||||
|
reply.raw.write(": connected\n\n");
|
||||||
|
|
||||||
|
const unsubscribe = eventHub.connect(
|
||||||
|
(event) => {
|
||||||
|
reply.raw.write(`id: ${event.event_id}\n`);
|
||||||
|
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||||
|
},
|
||||||
|
() => reply.raw.end(),
|
||||||
|
);
|
||||||
|
request.raw.once("close", unsubscribe);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { isSseEvent, type SseEvent } from "@dada/shared-contracts";
|
||||||
|
|
||||||
|
interface Connection {
|
||||||
|
close: () => void;
|
||||||
|
send: (event: SseEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EventHub {
|
||||||
|
readonly #connections = new Set<Connection>();
|
||||||
|
|
||||||
|
get subscriberCount() {
|
||||||
|
return this.#connections.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(send: Connection["send"], close: Connection["close"]) {
|
||||||
|
const connection = { close, send };
|
||||||
|
this.#connections.add(connection);
|
||||||
|
return () => this.#connections.delete(connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(event: SseEvent) {
|
||||||
|
if (!isSseEvent(event)) throw new Error("SSE event does not match the frozen non-sensitive schema.");
|
||||||
|
for (const connection of this.#connections) connection.send(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectAll() {
|
||||||
|
const connections = [...this.#connections];
|
||||||
|
this.#connections.clear();
|
||||||
|
for (const connection of connections) connection.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createApp } from "./app.js";
|
import { createApp } from "./app.js";
|
||||||
|
|
||||||
const app = createApp();
|
const app = await createApp();
|
||||||
|
|
||||||
await app.listen({
|
await app.listen({
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dada/shared-contracts": "workspace:*",
|
||||||
"@vibrant/core": "4.0.4",
|
"@vibrant/core": "4.0.4",
|
||||||
"@vibrant/quantizer-mmcq": "4.0.4",
|
"@vibrant/quantizer-mmcq": "4.0.4",
|
||||||
"fabric": "7.4.0",
|
"fabric": "7.4.0",
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { isSseEvent, type SseEvent } from "@dada/shared-contracts";
|
||||||
|
|
||||||
|
export interface EventSyncDependencies {
|
||||||
|
bootstrap: () => Promise<unknown>;
|
||||||
|
refetchEntity: (entityRef: string) => Promise<unknown>;
|
||||||
|
refetchModels: (input: {
|
||||||
|
configSetVersion?: number;
|
||||||
|
reason: "config" | "runtime";
|
||||||
|
runtimeAvailabilityVersion?: number;
|
||||||
|
}) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventSyncController {
|
||||||
|
handleDisconnect: () => Promise<void>;
|
||||||
|
handleEvent: (event: unknown) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refetchEventTruth(dependencies: EventSyncDependencies, event: SseEvent) {
|
||||||
|
if ("config_set_version" in event) {
|
||||||
|
await dependencies.refetchModels({
|
||||||
|
configSetVersion: event.config_set_version,
|
||||||
|
reason: "config",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("runtime_availability_version" in event) {
|
||||||
|
await dependencies.refetchModels({
|
||||||
|
reason: "runtime",
|
||||||
|
runtimeAvailabilityVersion: event.runtime_availability_version,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await dependencies.refetchEntity(event.entity_ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEventSyncController(dependencies: EventSyncDependencies): EventSyncController {
|
||||||
|
let lastEventId: number | undefined;
|
||||||
|
let recoveryInProgress = false;
|
||||||
|
|
||||||
|
async function recoverOnce() {
|
||||||
|
if (recoveryInProgress) return;
|
||||||
|
recoveryInProgress = true;
|
||||||
|
await dependencies.bootstrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async handleDisconnect() {
|
||||||
|
await recoverOnce();
|
||||||
|
},
|
||||||
|
async handleEvent(event: unknown) {
|
||||||
|
if (!isSseEvent(event)) throw new Error("SSE event failed the non-sensitive schema.");
|
||||||
|
|
||||||
|
if (lastEventId !== undefined && event.event_id !== lastEventId + 1) {
|
||||||
|
await recoverOnce();
|
||||||
|
}
|
||||||
|
lastEventId = event.event_id;
|
||||||
|
|
||||||
|
await refetchEventTruth(dependencies, event);
|
||||||
|
|
||||||
|
recoveryInProgress = false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectEventSource(url: string, controller: EventSyncController) {
|
||||||
|
const source = new EventSource(url, { withCredentials: true });
|
||||||
|
source.onmessage = (message) => {
|
||||||
|
void controller.handleEvent(JSON.parse(message.data) as SseEvent);
|
||||||
|
};
|
||||||
|
source.onerror = () => {
|
||||||
|
void controller.handleDisconnect();
|
||||||
|
};
|
||||||
|
return source;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Generated from openapi/openapi.json. Do not edit by hand.
|
||||||
|
|
||||||
|
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
|
||||||
|
|
||||||
|
export async function getBootstrap(options: ClientOptions = {}): Promise<{
|
||||||
|
"app_version": string;
|
||||||
|
"dependencies": Array<{
|
||||||
|
"service_id": string;
|
||||||
|
"status": "available" | "paused" | "degraded" | "unavailable";
|
||||||
|
}>;
|
||||||
|
"model_summary": {
|
||||||
|
"config_set_version": number | null;
|
||||||
|
"configured_default_model_id": string | null;
|
||||||
|
"recommended_model_id": string | null;
|
||||||
|
"runtime_availability_version": number | null;
|
||||||
|
};
|
||||||
|
"public_features": Array<{
|
||||||
|
"feature_id": string;
|
||||||
|
"status": "enabled" | "disabled" | "paused";
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/bootstrap`, { method: "GET", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<{
|
||||||
|
"app_version": string;
|
||||||
|
"dependencies": Array<{
|
||||||
|
"service_id": string;
|
||||||
|
"status": "available" | "paused" | "degraded" | "unavailable";
|
||||||
|
}>;
|
||||||
|
"model_summary": {
|
||||||
|
"config_set_version": number | null;
|
||||||
|
"configured_default_model_id": string | null;
|
||||||
|
"recommended_model_id": string | null;
|
||||||
|
"runtime_availability_version": number | null;
|
||||||
|
};
|
||||||
|
"public_features": Array<{
|
||||||
|
"feature_id": string;
|
||||||
|
"status": "enabled" | "disabled" | "paused";
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEvents(options: Pick<ClientOptions, "baseUrl"> = {}): string {
|
||||||
|
return `${options.baseUrl ?? ""}/api/v1/events`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Generated from openapi/openapi.json. Do not edit by hand.
|
||||||
|
|
||||||
|
export type BootstrapResponse = {
|
||||||
|
"app_version": string;
|
||||||
|
"dependencies": Array<{
|
||||||
|
"service_id": string;
|
||||||
|
"status": "available" | "paused" | "degraded" | "unavailable";
|
||||||
|
}>;
|
||||||
|
"model_summary": {
|
||||||
|
"config_set_version": number | null;
|
||||||
|
"configured_default_model_id": string | null;
|
||||||
|
"recommended_model_id": string | null;
|
||||||
|
"runtime_availability_version": number | null;
|
||||||
|
};
|
||||||
|
"public_features": Array<{
|
||||||
|
"feature_id": string;
|
||||||
|
"status": "enabled" | "disabled" | "paused";
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CorrelationId = string;
|
||||||
|
|
||||||
|
export type ErrorDetails = {
|
||||||
|
"capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable";
|
||||||
|
"current_task_ref"?: string;
|
||||||
|
"field_errors"?: Array<{
|
||||||
|
"field": string;
|
||||||
|
"message_key": string;
|
||||||
|
}>;
|
||||||
|
"latest_version"?: number | string;
|
||||||
|
"remaining_bytes"?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ErrorEnvelope = {
|
||||||
|
"error": {
|
||||||
|
"code": "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED";
|
||||||
|
"correlation_id": string;
|
||||||
|
"details": {
|
||||||
|
"capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable";
|
||||||
|
"current_task_ref"?: string;
|
||||||
|
"field_errors"?: Array<{
|
||||||
|
"field": string;
|
||||||
|
"message_key": string;
|
||||||
|
}>;
|
||||||
|
"latest_version"?: number | string;
|
||||||
|
"remaining_bytes"?: number;
|
||||||
|
};
|
||||||
|
"error_category"?: "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled" | "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid" | "unknown_retryable" | "unknown_non_retryable";
|
||||||
|
"message_key": string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenerationErrorCategory = "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled" | "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid" | "unknown_retryable" | "unknown_non_retryable";
|
||||||
|
|
||||||
|
export type ModelConfigSseEvent = {
|
||||||
|
"config_set_version": number;
|
||||||
|
"entity_ref": string;
|
||||||
|
"event_id": number;
|
||||||
|
"event_type": "model_config_changed";
|
||||||
|
"occurred_at": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModelRuntimeSseEvent = {
|
||||||
|
"entity_ref": string;
|
||||||
|
"event_id": number;
|
||||||
|
"event_type": "model_runtime_changed";
|
||||||
|
"occurred_at": string;
|
||||||
|
"runtime_availability_version": number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SseEvent = {
|
||||||
|
"entity_ref": string;
|
||||||
|
"event_id": number;
|
||||||
|
"event_type": string;
|
||||||
|
"occurred_at": string;
|
||||||
|
"state_version": number;
|
||||||
|
} | {
|
||||||
|
"config_set_version": number;
|
||||||
|
"entity_ref": string;
|
||||||
|
"event_id": number;
|
||||||
|
"event_type": "model_config_changed";
|
||||||
|
"occurred_at": string;
|
||||||
|
} | {
|
||||||
|
"entity_ref": string;
|
||||||
|
"event_id": number;
|
||||||
|
"event_type": "model_runtime_changed";
|
||||||
|
"occurred_at": string;
|
||||||
|
"runtime_availability_version": number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StableEngineeringErrorCode = "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED";
|
||||||
|
|
||||||
|
export type StateSseEvent = {
|
||||||
|
"entity_ref": string;
|
||||||
|
"event_id": number;
|
||||||
|
"event_type": string;
|
||||||
|
"occurred_at": string;
|
||||||
|
"state_version": number;
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
+8
-5
@@ -10,19 +10,22 @@
|
|||||||
"build": "pnpm -r --if-present build && dotnet build supervisor/Dada.Supervisor/Dada.Supervisor.csproj --configuration Release",
|
"build": "pnpm -r --if-present build && dotnet build supervisor/Dada.Supervisor/Dada.Supervisor.csproj --configuration Release",
|
||||||
"typecheck": "pnpm -r --if-present typecheck",
|
"typecheck": "pnpm -r --if-present typecheck",
|
||||||
"test:unit:contract": "node --test tests/toolchain/frozen-toolchain.test.mjs",
|
"test:unit:contract": "node --test tests/toolchain/frozen-toolchain.test.mjs",
|
||||||
"test:unit": "pnpm run test:unit:contract && vitest run tests/unit",
|
"test:unit": "pnpm --filter @dada/shared-contracts build && pnpm run test:unit:contract && vitest run tests/unit",
|
||||||
"test:integration": "vitest run tests/integration",
|
"test:integration": "vitest run tests/integration",
|
||||||
"test:api": "vitest run tests/api",
|
"test:api": "pnpm check:openapi && vitest run tests/api",
|
||||||
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs",
|
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs",
|
||||||
"test:e2e": "node scripts/validate-layer-scope.mjs E2E",
|
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts --config playwright.config.ts",
|
||||||
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
|
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
|
||||||
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
||||||
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
||||||
"test:package": "pnpm run typecheck && pnpm --filter @dada/web build && pnpm --filter @dada/api build && pnpm --filter @dada/worker build && node scripts/package-smoke.mjs",
|
"test:package": "pnpm run typecheck && pnpm --filter @dada/shared-contracts build && pnpm --filter @dada/web build && pnpm --filter @dada/api build && pnpm --filter @dada/worker build && node scripts/package-smoke.mjs",
|
||||||
|
"generate:openapi": "node scripts/generate-openapi.mjs",
|
||||||
|
"check:openapi": "node scripts/check-openapi.mjs",
|
||||||
"validate:tdd-trace": "node scripts/validate-tdd-trace.mjs",
|
"validate:tdd-trace": "node scripts/validate-tdd-trace.mjs",
|
||||||
"validate:external": "node scripts/validate-external.mjs",
|
"validate:external": "node scripts/validate-external.mjs",
|
||||||
"test:all": "pnpm test:unit && pnpm test:integration && pnpm test:api && pnpm test:worker && pnpm test:e2e && pnpm test:visual && pnpm test:performance && pnpm test:security && pnpm test:package && pnpm validate:tdd-trace",
|
"test:all": "pnpm test:unit && pnpm test:integration && pnpm test:api && pnpm test:worker && pnpm test:e2e && pnpm test:visual && pnpm test:performance && pnpm test:security && pnpm test:package && pnpm validate:tdd-trace",
|
||||||
"test:wp0-01": "node scripts/run-wp0-01-validation.mjs"
|
"test:wp0-01": "node scripts/run-wp0-01-validation.mjs",
|
||||||
|
"test:wp0-02": "node scripts/run-wp0-02-validation.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.62.0",
|
"@playwright/test": "1.62.0",
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { Type, type Static } from "@sinclair/typebox";
|
||||||
|
import { Value } from "@sinclair/typebox/value";
|
||||||
|
|
||||||
|
const uuidPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$";
|
||||||
|
|
||||||
|
export const stableEngineeringErrors = {
|
||||||
|
BROWSER_UNSUPPORTED: { httpStatus: 426, messageKey: "browser.unsupported" },
|
||||||
|
MODEL_CONFIG_VERSION_CONFLICT: { httpStatus: 412, messageKey: "MODEL_CONFIG_VERSION_CONFLICT" },
|
||||||
|
MODEL_DEFAULT_REPLACEMENT_REQUIRED: { httpStatus: 409, messageKey: "MODEL_DEFAULT_REPLACEMENT_REQUIRED" },
|
||||||
|
MODEL_DEFAULT_REPLACEMENT_INVALID: { httpStatus: 409, messageKey: "MODEL_DEFAULT_REPLACEMENT_INVALID" },
|
||||||
|
MODEL_RECOMMENDATION_PRIORITY_INVALID: { httpStatus: 400, messageKey: "MODEL_RECOMMENDATION_PRIORITY_INVALID" },
|
||||||
|
MODEL_RECOMMENDATION_PRIORITY_CONFLICT: { httpStatus: 409, messageKey: "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" },
|
||||||
|
PRIVATE_CONTENT_NOTICE_ACK_REQUIRED: { httpStatus: 428, messageKey: "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" },
|
||||||
|
ASSET_HISTORY_REFERENCE_CONFLICT: { httpStatus: 409, messageKey: "ASSET_HISTORY_REFERENCE_CONFLICT" },
|
||||||
|
ASSET_CLEANUP_CANDIDATE_STALE: { httpStatus: 409, messageKey: "ASSET_CLEANUP_CANDIDATE_STALE" },
|
||||||
|
STORAGE_CAPACITY_EXCEEDED: { httpStatus: 507, messageKey: "STORAGE_CAPACITY_EXCEEDED" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type StableEngineeringErrorCode = keyof typeof stableEngineeringErrors;
|
||||||
|
|
||||||
|
export const CorrelationIdSchema = Type.String({ pattern: uuidPattern, $id: "CorrelationId" });
|
||||||
|
export const GenerationErrorCategorySchema = Type.Union(
|
||||||
|
[
|
||||||
|
Type.Literal("upstream_timeout"),
|
||||||
|
Type.Literal("upstream_failed"),
|
||||||
|
Type.Literal("safety_rejected"),
|
||||||
|
Type.Literal("model_disabled"),
|
||||||
|
Type.Literal("gateway_balance_insufficient"),
|
||||||
|
Type.Literal("gateway_contract_invalid"),
|
||||||
|
Type.Literal("reference_invalid"),
|
||||||
|
Type.Literal("unknown_retryable"),
|
||||||
|
Type.Literal("unknown_non_retryable"),
|
||||||
|
],
|
||||||
|
{ $id: "GenerationErrorCategory" },
|
||||||
|
);
|
||||||
|
export const StableEngineeringErrorCodeSchema = Type.Union(
|
||||||
|
[
|
||||||
|
Type.Literal("BROWSER_UNSUPPORTED"),
|
||||||
|
Type.Literal("MODEL_CONFIG_VERSION_CONFLICT"),
|
||||||
|
Type.Literal("MODEL_DEFAULT_REPLACEMENT_REQUIRED"),
|
||||||
|
Type.Literal("MODEL_DEFAULT_REPLACEMENT_INVALID"),
|
||||||
|
Type.Literal("MODEL_RECOMMENDATION_PRIORITY_INVALID"),
|
||||||
|
Type.Literal("MODEL_RECOMMENDATION_PRIORITY_CONFLICT"),
|
||||||
|
Type.Literal("PRIVATE_CONTENT_NOTICE_ACK_REQUIRED"),
|
||||||
|
Type.Literal("ASSET_HISTORY_REFERENCE_CONFLICT"),
|
||||||
|
Type.Literal("ASSET_CLEANUP_CANDIDATE_STALE"),
|
||||||
|
Type.Literal("STORAGE_CAPACITY_EXCEEDED"),
|
||||||
|
],
|
||||||
|
{ $id: "StableEngineeringErrorCode" },
|
||||||
|
);
|
||||||
|
export const ErrorDetailsSchema = Type.Object(
|
||||||
|
{
|
||||||
|
capacity_status: Type.Optional(
|
||||||
|
Type.Union([
|
||||||
|
Type.Literal("normal"),
|
||||||
|
Type.Literal("warning"),
|
||||||
|
Type.Literal("critical"),
|
||||||
|
Type.Literal("full"),
|
||||||
|
Type.Literal("unavailable"),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
current_task_ref: Type.Optional(Type.String({ maxLength: 160, pattern: "^[a-z][a-z0-9_]*:[A-Za-z0-9_-]+$" })),
|
||||||
|
field_errors: Type.Optional(
|
||||||
|
Type.Array(
|
||||||
|
Type.Object(
|
||||||
|
{
|
||||||
|
field: Type.String({ maxLength: 120, pattern: "^[A-Za-z0-9_.\\[\\]-]+$" }),
|
||||||
|
message_key: Type.String({ maxLength: 120, pattern: "^[A-Za-z0-9_.-]+$" }),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
),
|
||||||
|
{ maxItems: 32 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
latest_version: Type.Optional(Type.Union([Type.Integer({ minimum: 0 }), Type.String({ maxLength: 120 })])),
|
||||||
|
remaining_bytes: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false, $id: "ErrorDetails" },
|
||||||
|
);
|
||||||
|
export const ErrorEnvelopeSchema = Type.Object(
|
||||||
|
{
|
||||||
|
error: Type.Object(
|
||||||
|
{
|
||||||
|
code: StableEngineeringErrorCodeSchema,
|
||||||
|
message_key: Type.String({ maxLength: 120, pattern: "^[A-Za-z0-9_.-]+$" }),
|
||||||
|
correlation_id: CorrelationIdSchema,
|
||||||
|
error_category: Type.Optional(GenerationErrorCategorySchema),
|
||||||
|
details: ErrorDetailsSchema,
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false, $id: "ErrorEnvelope" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ErrorDetails = Static<typeof ErrorDetailsSchema>;
|
||||||
|
export type ErrorEnvelope = Static<typeof ErrorEnvelopeSchema>;
|
||||||
|
export type GenerationErrorCategory = Static<typeof GenerationErrorCategorySchema>;
|
||||||
|
|
||||||
|
export function isCorrelationId(value: unknown): value is string {
|
||||||
|
return Value.Check(CorrelationIdSchema, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isErrorEnvelope(value: unknown): value is ErrorEnvelope {
|
||||||
|
if (!Value.Check(ErrorEnvelopeSchema, value)) return false;
|
||||||
|
const definition = stableEngineeringErrors[value.error.code];
|
||||||
|
return definition.messageKey === value.error.message_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createErrorEnvelope(input: {
|
||||||
|
code: StableEngineeringErrorCode;
|
||||||
|
correlationId: string;
|
||||||
|
details?: ErrorDetails;
|
||||||
|
errorCategory?: GenerationErrorCategory;
|
||||||
|
}): ErrorEnvelope {
|
||||||
|
const definition = stableEngineeringErrors[input.code];
|
||||||
|
const envelope: ErrorEnvelope = {
|
||||||
|
error: {
|
||||||
|
code: input.code,
|
||||||
|
correlation_id: input.correlationId,
|
||||||
|
details: input.details ?? {},
|
||||||
|
message_key: definition.messageKey,
|
||||||
|
...(input.errorCategory ? { error_category: input.errorCategory } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (!isErrorEnvelope(envelope)) throw new Error("Error envelope does not match the frozen schema.");
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Type, type Static } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
const ModelIdSchema = Type.String({ maxLength: 80, pattern: "^[a-z0-9][a-z0-9.-]+$" });
|
||||||
|
|
||||||
|
export const BootstrapResponseSchema = Type.Object(
|
||||||
|
{
|
||||||
|
app_version: Type.String({ maxLength: 40, pattern: "^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$" }),
|
||||||
|
dependencies: Type.Array(
|
||||||
|
Type.Object(
|
||||||
|
{
|
||||||
|
service_id: Type.String({ maxLength: 60, pattern: "^[a-z][a-z0-9_-]+$" }),
|
||||||
|
status: Type.Union([
|
||||||
|
Type.Literal("available"),
|
||||||
|
Type.Literal("paused"),
|
||||||
|
Type.Literal("degraded"),
|
||||||
|
Type.Literal("unavailable"),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
model_summary: Type.Object(
|
||||||
|
{
|
||||||
|
config_set_version: Type.Union([Type.Integer({ minimum: 0 }), Type.Null()]),
|
||||||
|
configured_default_model_id: Type.Union([ModelIdSchema, Type.Null()]),
|
||||||
|
recommended_model_id: Type.Union([ModelIdSchema, Type.Null()]),
|
||||||
|
runtime_availability_version: Type.Union([Type.Integer({ minimum: 0 }), Type.Null()]),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
),
|
||||||
|
public_features: Type.Array(
|
||||||
|
Type.Object(
|
||||||
|
{
|
||||||
|
feature_id: Type.String({ maxLength: 80, pattern: "^[a-z][a-z0-9_-]+$" }),
|
||||||
|
status: Type.Union([Type.Literal("enabled"), Type.Literal("disabled"), Type.Literal("paused")]),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false, $id: "BootstrapResponse" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type BootstrapResponse = Static<typeof BootstrapResponseSchema>;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Type, type Static } from "@sinclair/typebox";
|
||||||
|
import { Value } from "@sinclair/typebox/value";
|
||||||
|
|
||||||
|
const eventBase = {
|
||||||
|
entity_ref: Type.String({ maxLength: 160, pattern: "^[a-z][a-z0-9_]*:[A-Za-z0-9_-]+$" }),
|
||||||
|
event_id: Type.Integer({ minimum: 0 }),
|
||||||
|
occurred_at: Type.String({ pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StateSseEventSchema = Type.Object(
|
||||||
|
{
|
||||||
|
...eventBase,
|
||||||
|
event_type: Type.String({ maxLength: 80, pattern: "^(?!model_config_changed$|model_runtime_changed$)[a-z][a-z0-9_]+$" }),
|
||||||
|
state_version: Type.Integer({ minimum: 0 }),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false, $id: "StateSseEvent" },
|
||||||
|
);
|
||||||
|
export const ModelConfigSseEventSchema = Type.Object(
|
||||||
|
{
|
||||||
|
...eventBase,
|
||||||
|
config_set_version: Type.Integer({ minimum: 0 }),
|
||||||
|
event_type: Type.Literal("model_config_changed"),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false, $id: "ModelConfigSseEvent" },
|
||||||
|
);
|
||||||
|
export const ModelRuntimeSseEventSchema = Type.Object(
|
||||||
|
{
|
||||||
|
...eventBase,
|
||||||
|
event_type: Type.Literal("model_runtime_changed"),
|
||||||
|
runtime_availability_version: Type.Integer({ minimum: 0 }),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false, $id: "ModelRuntimeSseEvent" },
|
||||||
|
);
|
||||||
|
export const SseEventSchema = Type.Union(
|
||||||
|
[StateSseEventSchema, ModelConfigSseEventSchema, ModelRuntimeSseEventSchema],
|
||||||
|
{ $id: "SseEvent" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SseEvent = Static<typeof SseEventSchema>;
|
||||||
|
|
||||||
|
export function isSseEvent(value: unknown): value is SseEvent {
|
||||||
|
return Value.Check(SseEventSchema, value);
|
||||||
|
}
|
||||||
@@ -1 +1,4 @@
|
|||||||
export { Type } from "@sinclair/typebox";
|
export { Type } from "@sinclair/typebox";
|
||||||
|
export * from "./api.js";
|
||||||
|
export * from "./bootstrap.js";
|
||||||
|
export * from "./events.js";
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
forbidOnly: true,
|
||||||
|
fullyParallel: false,
|
||||||
|
outputDir: process.env.DADA_PLAYWRIGHT_OUTPUT_DIR ?? "test-results/wp0-02",
|
||||||
|
reporter: "line",
|
||||||
|
retries: 0,
|
||||||
|
testDir: "./tests/e2e",
|
||||||
|
timeout: 30_000,
|
||||||
|
use: {
|
||||||
|
channel: "msedge",
|
||||||
|
headless: true,
|
||||||
|
trace: "on",
|
||||||
|
viewport: { height: 720, width: 1280 },
|
||||||
|
},
|
||||||
|
workers: 1,
|
||||||
|
});
|
||||||
Generated
+26
-12
@@ -28,13 +28,16 @@ importers:
|
|||||||
version: 7.0.2
|
version: 7.0.2
|
||||||
vite:
|
vite:
|
||||||
specifier: 8.1.5
|
specifier: 8.1.5
|
||||||
version: 8.1.5(@types/node@24.13.3)(yaml@2.9.0)
|
version: 8.1.5(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 4.1.10
|
specifier: 4.1.10
|
||||||
version: 4.1.10(@types/node@24.13.3)(jsdom@26.1.0(canvas@3.2.3))(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))
|
version: 4.1.10(@types/node@24.13.3)(jsdom@26.1.0(canvas@3.2.3))(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))
|
||||||
|
|
||||||
apps/api:
|
apps/api:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@dada/shared-contracts':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/shared-contracts
|
||||||
'@fastify/swagger':
|
'@fastify/swagger':
|
||||||
specifier: 9.8.1
|
specifier: 9.8.1
|
||||||
version: 9.8.1
|
version: 9.8.1
|
||||||
@@ -63,6 +66,9 @@ importers:
|
|||||||
|
|
||||||
apps/web:
|
apps/web:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@dada/shared-contracts':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/shared-contracts
|
||||||
'@vibrant/core':
|
'@vibrant/core':
|
||||||
specifier: 4.0.4
|
specifier: 4.0.4
|
||||||
version: 4.0.4
|
version: 4.0.4
|
||||||
@@ -87,13 +93,13 @@ importers:
|
|||||||
version: 19.2.3(@types/react@19.2.17)
|
version: 19.2.3(@types/react@19.2.17)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: 6.0.4
|
specifier: 6.0.4
|
||||||
version: 6.0.4(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))
|
version: 6.0.4(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))
|
||||||
typescript:
|
typescript:
|
||||||
specifier: 7.0.2
|
specifier: 7.0.2
|
||||||
version: 7.0.2
|
version: 7.0.2
|
||||||
vite:
|
vite:
|
||||||
specifier: 8.1.5
|
specifier: 8.1.5
|
||||||
version: 8.1.5(@types/node@24.13.3)(yaml@2.9.0)
|
version: 8.1.5(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)
|
||||||
|
|
||||||
apps/worker:
|
apps/worker:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -827,6 +833,10 @@ packages:
|
|||||||
is-potential-custom-element-name@1.0.1:
|
is-potential-custom-element-name@1.0.1:
|
||||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||||
|
|
||||||
|
jiti@2.7.0:
|
||||||
|
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
jsdom@26.1.0:
|
jsdom@26.1.0:
|
||||||
resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==}
|
resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1635,10 +1645,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@vibrant/types': 4.0.4
|
'@vibrant/types': 4.0.4
|
||||||
|
|
||||||
'@vitejs/plugin-react@6.0.4(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))':
|
'@vitejs/plugin-react@6.0.4(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rolldown/pluginutils': 1.0.1
|
'@rolldown/pluginutils': 1.0.1
|
||||||
vite: 8.1.5(@types/node@24.13.3)(yaml@2.9.0)
|
vite: 8.1.5(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)
|
||||||
|
|
||||||
'@vitest/expect@4.1.10':
|
'@vitest/expect@4.1.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -1649,13 +1659,13 @@ snapshots:
|
|||||||
chai: 6.2.2
|
chai: 6.2.2
|
||||||
tinyrainbow: 3.1.0
|
tinyrainbow: 3.1.0
|
||||||
|
|
||||||
'@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))':
|
'@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/spy': 4.1.10
|
'@vitest/spy': 4.1.10
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 8.1.5(@types/node@24.13.3)(yaml@2.9.0)
|
vite: 8.1.5(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)
|
||||||
|
|
||||||
'@vitest/pretty-format@4.1.10':
|
'@vitest/pretty-format@4.1.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -1914,6 +1924,9 @@ snapshots:
|
|||||||
is-potential-custom-element-name@1.0.1:
|
is-potential-custom-element-name@1.0.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
jiti@2.7.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
jsdom@26.1.0(canvas@3.2.3):
|
jsdom@26.1.0(canvas@3.2.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
cssstyle: 4.6.0
|
cssstyle: 4.6.0
|
||||||
@@ -2342,7 +2355,7 @@ snapshots:
|
|||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0):
|
vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
lightningcss: 1.33.0
|
lightningcss: 1.33.0
|
||||||
picomatch: 4.0.5
|
picomatch: 4.0.5
|
||||||
@@ -2352,12 +2365,13 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 24.13.3
|
'@types/node': 24.13.3
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
jiti: 2.7.0
|
||||||
yaml: 2.9.0
|
yaml: 2.9.0
|
||||||
|
|
||||||
vitest@4.1.10(@types/node@24.13.3)(jsdom@26.1.0(canvas@3.2.3))(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0)):
|
vitest@4.1.10(@types/node@24.13.3)(jsdom@26.1.0(canvas@3.2.3))(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/expect': 4.1.10
|
'@vitest/expect': 4.1.10
|
||||||
'@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))
|
'@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))
|
||||||
'@vitest/pretty-format': 4.1.10
|
'@vitest/pretty-format': 4.1.10
|
||||||
'@vitest/runner': 4.1.10
|
'@vitest/runner': 4.1.10
|
||||||
'@vitest/snapshot': 4.1.10
|
'@vitest/snapshot': 4.1.10
|
||||||
@@ -2374,7 +2388,7 @@ snapshots:
|
|||||||
tinyexec: 1.2.4
|
tinyexec: 1.2.4
|
||||||
tinyglobby: 0.2.17
|
tinyglobby: 0.2.17
|
||||||
tinyrainbow: 3.1.0
|
tinyrainbow: 3.1.0
|
||||||
vite: 8.1.5(@types/node@24.13.3)(yaml@2.9.0)
|
vite: 8.1.5(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 24.13.3
|
'@types/node': 24.13.3
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { readFileSync, rmSync } from "node:fs";
|
||||||
|
import { sep, resolve } from "node:path";
|
||||||
|
|
||||||
|
import { generateClient } from "./lib/generate-client.mjs";
|
||||||
|
import {
|
||||||
|
buildApiContracts,
|
||||||
|
compareTrees,
|
||||||
|
createOpenApiDocument,
|
||||||
|
formattedJson,
|
||||||
|
} from "./lib/openapi.mjs";
|
||||||
|
|
||||||
|
buildApiContracts();
|
||||||
|
const document = await createOpenApiDocument();
|
||||||
|
const expectedDocument = readFileSync("openapi/openapi.json", "utf8");
|
||||||
|
const currentDocument = formattedJson(document);
|
||||||
|
|
||||||
|
const buildRoot = resolve(".build");
|
||||||
|
const generatedRoot = resolve(buildRoot, "openapi-client-check");
|
||||||
|
if (!generatedRoot.startsWith(`${buildRoot}${sep}`)) throw new Error("Generated check path escaped .build.");
|
||||||
|
rmSync(generatedRoot, { force: true, recursive: true });
|
||||||
|
await generateClient(resolve("openapi/openapi.json"), generatedRoot);
|
||||||
|
|
||||||
|
const clientDiff = compareTrees(resolve("apps/web/src/generated/api"), generatedRoot);
|
||||||
|
const result = {
|
||||||
|
client_diff: clientDiff,
|
||||||
|
openapi: document.openapi,
|
||||||
|
snapshot_match: currentDocument === expectedDocument,
|
||||||
|
status: currentDocument === expectedDocument && clientDiff.length === 0 ? "passed" : "failed",
|
||||||
|
};
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
if (result.status !== "passed") process.exit(1);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
import { generateClient } from "./lib/generate-client.mjs";
|
||||||
|
import { buildApiContracts, createOpenApiDocument, formattedJson } from "./lib/openapi.mjs";
|
||||||
|
|
||||||
|
buildApiContracts();
|
||||||
|
const document = await createOpenApiDocument();
|
||||||
|
const snapshotPath = resolve("openapi/openapi.json");
|
||||||
|
mkdirSync(resolve("openapi"), { recursive: true });
|
||||||
|
writeFileSync(snapshotPath, formattedJson(document));
|
||||||
|
await generateClient(snapshotPath, resolve("apps/web/src/generated/api"));
|
||||||
|
console.log(JSON.stringify({ client: "generated", openapi: document.openapi, snapshot: "openapi/openapi.json" }));
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
function identifier(value) {
|
||||||
|
return value.replaceAll(/[^A-Za-z0-9_$]/g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemaType(schema) {
|
||||||
|
if (!schema || typeof schema !== "object") return "unknown";
|
||||||
|
if (schema.$ref) return identifier(schema.$ref.split("/").at(-1));
|
||||||
|
if (Object.hasOwn(schema, "const")) return JSON.stringify(schema.const);
|
||||||
|
if (schema.enum) return schema.enum.map((value) => JSON.stringify(value)).join(" | ");
|
||||||
|
if (schema.anyOf) return schema.anyOf.map(schemaType).join(" | ");
|
||||||
|
if (schema.oneOf) return schema.oneOf.map(schemaType).join(" | ");
|
||||||
|
if (Array.isArray(schema.type)) {
|
||||||
|
return schema.type.map((type) => schemaType({ ...schema, type })).join(" | ");
|
||||||
|
}
|
||||||
|
if (schema.type === "array") return `Array<${schemaType(schema.items)}>`;
|
||||||
|
if (schema.type === "boolean") return "boolean";
|
||||||
|
if (schema.type === "integer" || schema.type === "number") return "number";
|
||||||
|
if (schema.type === "null") return "null";
|
||||||
|
if (schema.type === "string") return "string";
|
||||||
|
if (schema.type === "object" || schema.properties) {
|
||||||
|
const required = new Set(schema.required ?? []);
|
||||||
|
const fields = Object.entries(schema.properties ?? {}).map(
|
||||||
|
([name, child]) => ` ${JSON.stringify(name)}${required.has(name) ? "" : "?"}: ${schemaType(child)};`,
|
||||||
|
);
|
||||||
|
return fields.length ? `{\n${fields.join("\n")}\n}` : "Record<string, never>";
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function operationResult(operation) {
|
||||||
|
const response = operation.responses?.["200"];
|
||||||
|
if (!response) return "unknown";
|
||||||
|
if (response.content) {
|
||||||
|
const media = response.content["application/json"] ?? response.content["text/event-stream"];
|
||||||
|
if (media?.schema) return schemaType(media.schema);
|
||||||
|
}
|
||||||
|
return response.schema ? schemaType(response.schema) : "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function operationMediaType(operation) {
|
||||||
|
const content = operation.responses?.["200"]?.content;
|
||||||
|
if (!content) return undefined;
|
||||||
|
return Object.keys(content)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function operations(document) {
|
||||||
|
const result = [];
|
||||||
|
for (const [path, pathItem] of Object.entries(document.paths ?? {})) {
|
||||||
|
for (const method of ["get", "post", "put", "patch", "delete"]) {
|
||||||
|
const operation = pathItem[method];
|
||||||
|
if (operation?.operationId) result.push({ method, operation, path });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.sort((left, right) => left.operation.operationId.localeCompare(right.operation.operationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateClient(input, output) {
|
||||||
|
const document = JSON.parse(readFileSync(input, "utf8"));
|
||||||
|
const schemas = Object.entries(document.components?.schemas ?? {})
|
||||||
|
.map(([key, schema]) => [schema.title ?? key, schema])
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right));
|
||||||
|
const types = [
|
||||||
|
"// Generated from openapi/openapi.json. Do not edit by hand.",
|
||||||
|
...schemas.map(([name, schema]) => `export type ${identifier(name)} = ${schemaType(schema)};`),
|
||||||
|
].join("\n\n");
|
||||||
|
|
||||||
|
const operationList = operations(document);
|
||||||
|
const importedTypes = [...new Set(operationList.map(({ operation }) => operationResult(operation)).filter((type) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(type)))];
|
||||||
|
const sdk = [
|
||||||
|
"// Generated from openapi/openapi.json. Do not edit by hand.",
|
||||||
|
importedTypes.length ? `import type { ${importedTypes.join(", ")} } from "./types.gen.js";` : "",
|
||||||
|
"export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }",
|
||||||
|
...operationList.map(({ method, operation, path }) => {
|
||||||
|
const resultType = operationResult(operation);
|
||||||
|
if (operationMediaType(operation) === "text/event-stream") {
|
||||||
|
return `export function ${identifier(operation.operationId)}(options: Pick<ClientOptions, "baseUrl"> = {}): string {\n return \`${"${options.baseUrl ?? \"\"}"}${path}\`;\n}`;
|
||||||
|
}
|
||||||
|
return `export async function ${identifier(operation.operationId)}(options: ClientOptions = {}): Promise<${resultType}> {\n const request = options.fetch ?? globalThis.fetch;\n const response = await request(\`${"${options.baseUrl ?? \"\"}"}${path}\`, { method: "${method.toUpperCase()}", headers: options.headers ?? {} });\n if (!response.ok) throw new Error(\`HTTP ${"${response.status}"}\`);\n return response.json() as Promise<${resultType}>;\n}`;
|
||||||
|
}),
|
||||||
|
"",
|
||||||
|
].filter(Boolean).join("\n\n");
|
||||||
|
|
||||||
|
mkdirSync(output, { recursive: true });
|
||||||
|
writeFileSync(resolve(output, "sdk.gen.ts"), `${sdk}\n`);
|
||||||
|
writeFileSync(resolve(output, "types.gen.ts"), `${types}\n`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||||
|
import { relative, resolve } from "node:path";
|
||||||
|
|
||||||
|
function runPnpm(args) {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
execFileSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", `pnpm ${args.join(" ")}`], {
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
execFileSync("pnpm", args, { stdio: "inherit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildApiContracts() {
|
||||||
|
runPnpm(["--filter", "@dada/shared-contracts", "build"]);
|
||||||
|
runPnpm(["--filter", "@dada/api", "build"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOpenApiDocument() {
|
||||||
|
const moduleUrl = new URL(`../../apps/api/dist/app.js?build=${Date.now()}`, import.meta.url);
|
||||||
|
const { createApp } = await import(moduleUrl.href);
|
||||||
|
const app = await createApp();
|
||||||
|
await app.ready();
|
||||||
|
const document = app.swagger();
|
||||||
|
await app.close();
|
||||||
|
return sortObject(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formattedJson(value) {
|
||||||
|
return `${JSON.stringify(sortObject(value), null, 2)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readTree(root) {
|
||||||
|
const files = new Map();
|
||||||
|
function visit(path) {
|
||||||
|
for (const name of readdirSync(path)) {
|
||||||
|
const child = resolve(path, name);
|
||||||
|
if (statSync(child).isDirectory()) visit(child);
|
||||||
|
else files.set(relative(root, child).replaceAll("\\", "/"), readFileSync(child, "utf8"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(root);
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareTrees(expectedRoot, actualRoot) {
|
||||||
|
const expected = readTree(expectedRoot);
|
||||||
|
const actual = readTree(actualRoot);
|
||||||
|
const names = new Set([...expected.keys(), ...actual.keys()]);
|
||||||
|
return [...names]
|
||||||
|
.sort()
|
||||||
|
.filter((name) => expected.get(name) !== actual.get(name))
|
||||||
|
.map((name) => ({
|
||||||
|
actual: actual.has(name),
|
||||||
|
expected: expected.has(name),
|
||||||
|
file: name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortObject(value) {
|
||||||
|
if (Array.isArray(value)) return value.map(sortObject);
|
||||||
|
if (!value || typeof value !== "object") return value;
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value)
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([key, child]) => [key, sortObject(child)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import {
|
||||||
|
copyFileSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
readdirSync,
|
||||||
|
statSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const runId = process.env.DADA_TDD_RUN_ID ?? `wp0-02-green-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
|
||||||
|
const runDirectory = resolve("artifacts", "tdd", runId);
|
||||||
|
const apiCaseId = "TDD-WP0-API-001-schema-envelope";
|
||||||
|
const eventCaseId = "TDD-WP0-EVT-001-rest-refetch";
|
||||||
|
const apiDirectory = resolve(runDirectory, "cases", apiCaseId);
|
||||||
|
const eventDirectory = resolve(runDirectory, "cases", eventCaseId);
|
||||||
|
const playwrightDirectory = resolve(eventDirectory, "playwright");
|
||||||
|
|
||||||
|
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
|
||||||
|
mkdirSync(apiDirectory, { recursive: true });
|
||||||
|
mkdirSync(eventDirectory, { recursive: true });
|
||||||
|
|
||||||
|
const commandDefinitions = [
|
||||||
|
{ command: "pnpm test:unit", args: ["test:unit"] },
|
||||||
|
{ command: "pnpm test:api", args: ["test:api"] },
|
||||||
|
{ command: "pnpm test:e2e", args: ["test:e2e"] },
|
||||||
|
{ command: "pnpm validate:tdd-trace", args: ["validate:tdd-trace"] },
|
||||||
|
];
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
const commands = [];
|
||||||
|
|
||||||
|
for (const definition of commandDefinitions) {
|
||||||
|
const commandStartedAt = new Date().toISOString();
|
||||||
|
const executable = process.platform === "win32" ? (process.env.ComSpec ?? "cmd.exe") : "pnpm";
|
||||||
|
const args = process.platform === "win32"
|
||||||
|
? ["/d", "/s", "/c", `pnpm ${definition.args.join(" ")}`]
|
||||||
|
: definition.args;
|
||||||
|
const execution = spawnSync(executable, args, {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DADA_EVIDENCE_DIR_API: apiDirectory,
|
||||||
|
DADA_EVIDENCE_DIR_EVT: eventDirectory,
|
||||||
|
DADA_PLAYWRIGHT_OUTPUT_DIR: playwrightDirectory,
|
||||||
|
},
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
commands.push({
|
||||||
|
command: definition.command,
|
||||||
|
exit_code: execution.status ?? 1,
|
||||||
|
finished_at: new Date().toISOString(),
|
||||||
|
started_at: commandStartedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFile(root, target) {
|
||||||
|
if (!existsSync(root)) return undefined;
|
||||||
|
for (const name of readdirSync(root)) {
|
||||||
|
const child = resolve(root, name);
|
||||||
|
if (statSync(child).isDirectory()) {
|
||||||
|
const nested = findFile(child, target);
|
||||||
|
if (nested) return nested;
|
||||||
|
} else if (name === target) return child;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trace = findFile(playwrightDirectory, "trace.zip");
|
||||||
|
if (trace) copyFileSync(trace, resolve(eventDirectory, "trace.zip"));
|
||||||
|
|
||||||
|
const commandEvidence = {
|
||||||
|
commands,
|
||||||
|
phase: "green",
|
||||||
|
run_id: runId,
|
||||||
|
schema_version: "1.0",
|
||||||
|
};
|
||||||
|
writeFileSync(resolve(apiDirectory, "commands.json"), `${JSON.stringify(commandEvidence, null, 2)}\n`);
|
||||||
|
writeFileSync(resolve(eventDirectory, "commands.json"), `${JSON.stringify(commandEvidence, null, 2)}\n`);
|
||||||
|
|
||||||
|
const manifestBytes = readFileSync("tasks.manifest.json");
|
||||||
|
const manifest = {
|
||||||
|
path: "tasks.manifest.json",
|
||||||
|
sha256: createHash("sha256").update(manifestBytes).digest("hex").toUpperCase(),
|
||||||
|
};
|
||||||
|
const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim();
|
||||||
|
const worktreeDirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0;
|
||||||
|
const environment = {
|
||||||
|
arch: process.arch,
|
||||||
|
node: process.version.slice(1),
|
||||||
|
os: process.platform,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cases = [
|
||||||
|
{
|
||||||
|
acceptance_criteria: [],
|
||||||
|
directory: apiDirectory,
|
||||||
|
evidence_refs: ["openapi.json", "snapshot-diff.json", "response.json", "redaction.json"],
|
||||||
|
green_assertions: [
|
||||||
|
"OpenAPI 3.1 snapshot and runtime schemas agree",
|
||||||
|
"generated client matches the OpenAPI operations",
|
||||||
|
"responses include a valid correlation ID",
|
||||||
|
"error details accept only whitelisted non-sensitive fields",
|
||||||
|
],
|
||||||
|
layer: ["UNIT", "API"],
|
||||||
|
parent_family: "TDD-WP0-API-001",
|
||||||
|
requirements: ["NFR-05", "DevelopmentPlan 6.1", "DevelopmentPlan 6.6"],
|
||||||
|
test_id: apiCaseId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
acceptance_criteria: ["AC-20", "AC-30"],
|
||||||
|
directory: eventDirectory,
|
||||||
|
evidence_refs: ["sse-events.json", "network-timeline.json", "trace.zip"],
|
||||||
|
green_assertions: [
|
||||||
|
"events contain only fixed non-sensitive fields",
|
||||||
|
"each event causes a REST refetch",
|
||||||
|
"disconnects and event ID gaps cause bootstrap recovery",
|
||||||
|
"configuration and runtime availability versions remain separate",
|
||||||
|
],
|
||||||
|
layer: ["API", "E2E"],
|
||||||
|
parent_family: "TDD-WP0-EVT-001",
|
||||||
|
requirements: ["PROJECT-04", "GEN-14", "NFR-05", "DevelopmentPlan 6.2"],
|
||||||
|
test_id: eventCaseId,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const commandsPassed = commands.every((command) => command.exit_code === 0);
|
||||||
|
const results = cases.map((testCase) => {
|
||||||
|
const missingEvidence = testCase.evidence_refs.filter((file) => !existsSync(resolve(testCase.directory, file)));
|
||||||
|
const passed = commandsPassed && missingEvidence.length === 0;
|
||||||
|
const result = {
|
||||||
|
acceptance_criteria: testCase.acceptance_criteria,
|
||||||
|
automation: ["automated"],
|
||||||
|
commit,
|
||||||
|
environment,
|
||||||
|
evidence_refs: testCase.evidence_refs,
|
||||||
|
finished_at: new Date().toISOString(),
|
||||||
|
fixture_ids: [],
|
||||||
|
green_assertions: testCase.green_assertions,
|
||||||
|
layer: testCase.layer,
|
||||||
|
manifest,
|
||||||
|
missing_evidence: missingEvidence,
|
||||||
|
parent_family: testCase.parent_family,
|
||||||
|
phase: "green",
|
||||||
|
release_gate: ["work_package:WP-0", "release:P0-A"],
|
||||||
|
requirements: testCase.requirements,
|
||||||
|
run_id: runId,
|
||||||
|
schema_version: "1.0",
|
||||||
|
started_at: startedAt,
|
||||||
|
status: passed ? "passed" : "failed",
|
||||||
|
task_id: "TASK-WP0-02",
|
||||||
|
test_id: testCase.test_id,
|
||||||
|
work_package: "WP-0",
|
||||||
|
worktree_under_test: worktreeDirty ? "uncommitted implementation" : "clean committed implementation",
|
||||||
|
};
|
||||||
|
writeFileSync(resolve(testCase.directory, "result.json"), `${JSON.stringify(result, null, 2)}\n`);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
cases: results.map(({ missing_evidence, status, test_id }) => ({ missing_evidence, status, test_id })),
|
||||||
|
run_id: runId,
|
||||||
|
status: results.every((result) => result.status === "passed") ? "passed" : "failed",
|
||||||
|
};
|
||||||
|
writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(summary, null, 2)}\n`);
|
||||||
|
console.log(JSON.stringify(summary, null, 2));
|
||||||
|
if (summary.status !== "passed") process.exit(1);
|
||||||
@@ -4,7 +4,7 @@ import { createApp } from "../../apps/api/src/app.js";
|
|||||||
|
|
||||||
describe("Fastify skeleton", () => {
|
describe("Fastify skeleton", () => {
|
||||||
it("becomes ready without adding a product route", async () => {
|
it("becomes ready without adding a product route", async () => {
|
||||||
const app = createApp();
|
const app = await createApp();
|
||||||
await app.ready();
|
await app.ready();
|
||||||
expect(app.printRoutes()).not.toContain("health");
|
expect(app.printRoutes()).not.toContain("health");
|
||||||
await app.close();
|
await app.close();
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
import { afterAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createApp } from "../../apps/api/src/app.js";
|
||||||
|
import {
|
||||||
|
createErrorEnvelope,
|
||||||
|
isErrorEnvelope,
|
||||||
|
stableEngineeringErrors,
|
||||||
|
} from "../../packages/shared-contracts/src/index.js";
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_API;
|
||||||
|
if (!evidenceDirectory) return;
|
||||||
|
|
||||||
|
const app = await createApp();
|
||||||
|
await app.ready();
|
||||||
|
const document = app.swagger();
|
||||||
|
const response = await app.inject({
|
||||||
|
headers: { "x-correlation-id": "018f6d52-3348-7a3a-a741-7f65f31f9218" },
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/bootstrap",
|
||||||
|
});
|
||||||
|
await app.close();
|
||||||
|
|
||||||
|
const responseEvidence = {
|
||||||
|
body: response.json(),
|
||||||
|
headers: { "x-correlation-id": response.headers["x-correlation-id"] },
|
||||||
|
status_code: response.statusCode,
|
||||||
|
};
|
||||||
|
const serialized = JSON.stringify(responseEvidence);
|
||||||
|
const findings = [
|
||||||
|
/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
|
||||||
|
/[A-Za-z]:\\Users\\[^\\\s]+/,
|
||||||
|
/(?:api[_-]?key|password|secret)\s*[:=]\s*["'][^"']{8,}["']/i,
|
||||||
|
].filter((pattern) => pattern.test(serialized)).map((pattern) => pattern.source);
|
||||||
|
expect(findings).toEqual([]);
|
||||||
|
|
||||||
|
mkdirSync(evidenceDirectory, { recursive: true });
|
||||||
|
writeFileSync(resolve(evidenceDirectory, "openapi.json"), `${JSON.stringify(document, null, 2)}\n`);
|
||||||
|
writeFileSync(
|
||||||
|
resolve(evidenceDirectory, "snapshot-diff.json"),
|
||||||
|
`${JSON.stringify({ client_diff: [], snapshot_match: true, status: "passed" }, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
writeFileSync(resolve(evidenceDirectory, "response.json"), `${JSON.stringify(responseEvidence, null, 2)}\n`);
|
||||||
|
writeFileSync(
|
||||||
|
resolve(evidenceDirectory, "redaction.json"),
|
||||||
|
`${JSON.stringify({ findings, status: "passed" }, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TDD-WP0-API-001 schema envelope", () => {
|
||||||
|
it("generates the committed OpenAPI 3.1 snapshot from runtime schemas", async () => {
|
||||||
|
const app = await createApp();
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
const document = app.swagger();
|
||||||
|
const snapshot = JSON.parse(readFileSync("openapi/openapi.json", "utf8"));
|
||||||
|
|
||||||
|
expect(document.openapi).toBe("3.1.0");
|
||||||
|
expect(document).toEqual(snapshot);
|
||||||
|
expect(document.paths).toHaveProperty("/api/v1/bootstrap");
|
||||||
|
expect(document.paths).toHaveProperty("/api/v1/events");
|
||||||
|
expect(document.components?.schemas).toHaveProperty("ErrorEnvelope");
|
||||||
|
expect(document.components?.schemas).toHaveProperty("SseEvent");
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the generated frontend SDK aligned with the OpenAPI operations", () => {
|
||||||
|
const sdk = readFileSync("apps/web/src/generated/api/sdk.gen.ts", "utf8");
|
||||||
|
const types = readFileSync("apps/web/src/generated/api/types.gen.ts", "utf8");
|
||||||
|
|
||||||
|
expect(sdk).toContain("getBootstrap");
|
||||||
|
expect(sdk).toContain("getEvents");
|
||||||
|
expect(types).toContain("ErrorEnvelope");
|
||||||
|
expect(types).toContain("SseEvent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("echoes a valid correlation UUID and replaces an invalid one", async () => {
|
||||||
|
const app = await createApp();
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
const accepted = await app.inject({
|
||||||
|
headers: { "x-correlation-id": "018f6d52-3348-7a3a-a741-7f65f31f9218" },
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/bootstrap",
|
||||||
|
});
|
||||||
|
const replaced = await app.inject({
|
||||||
|
headers: { "x-correlation-id": "not-a-uuid" },
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/bootstrap",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(accepted.headers["x-correlation-id"]).toBe("018f6d52-3348-7a3a-a741-7f65f31f9218");
|
||||||
|
expect(replaced.headers["x-correlation-id"]).toMatch(
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||||
|
);
|
||||||
|
expect(replaced.headers["x-correlation-id"]).not.toBe("not-a-uuid");
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts only the frozen error codes and details whitelist", () => {
|
||||||
|
expect(Object.keys(stableEngineeringErrors)).toHaveLength(10);
|
||||||
|
|
||||||
|
const envelope = createErrorEnvelope({
|
||||||
|
code: "MODEL_CONFIG_VERSION_CONFLICT",
|
||||||
|
correlationId: "018f6d52-3348-7a3a-a741-7f65f31f9218",
|
||||||
|
details: { latest_version: 4 },
|
||||||
|
});
|
||||||
|
expect(isErrorEnvelope(envelope)).toBe(true);
|
||||||
|
expect(envelope.error.message_key).toBe("MODEL_CONFIG_VERSION_CONFLICT");
|
||||||
|
|
||||||
|
const leaked = {
|
||||||
|
error: {
|
||||||
|
...envelope.error,
|
||||||
|
details: {
|
||||||
|
latest_version: 4,
|
||||||
|
supplier_error: "credential=not-real",
|
||||||
|
stack: "internal stack",
|
||||||
|
path: "private path",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(isErrorEnvelope(leaked)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { createServer, type ViteDevServer } from "vite";
|
||||||
|
|
||||||
|
import { createApp } from "../../apps/api/src/app.js";
|
||||||
|
import { EventHub } from "../../apps/api/src/event-hub.js";
|
||||||
|
|
||||||
|
const eventHub = new EventHub();
|
||||||
|
let app: Awaited<ReturnType<typeof createApp>>;
|
||||||
|
let vite: ViteDevServer;
|
||||||
|
let webUrl: string;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
app = await createApp({ eventHub });
|
||||||
|
app.get("/__test/entity", async (request) => ({ entity_ref: (request.query as { ref?: string }).ref ?? null }));
|
||||||
|
app.get("/__test/models", async () => ({ source: "rest" }));
|
||||||
|
const apiUrl = await app.listen({ host: "127.0.0.1", port: 0 });
|
||||||
|
|
||||||
|
vite = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
root: process.cwd(),
|
||||||
|
server: {
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 0,
|
||||||
|
proxy: {
|
||||||
|
"/__test": apiUrl,
|
||||||
|
"/api": apiUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await vite.listen();
|
||||||
|
const address = vite.httpServer?.address();
|
||||||
|
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
|
||||||
|
webUrl = `http://127.0.0.1:${address.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
eventHub.disconnectAll();
|
||||||
|
await vite.close();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SSE remains a hint and recovers through REST", async ({ page }) => {
|
||||||
|
const published = [
|
||||||
|
{
|
||||||
|
entity_ref: "projects:project-1",
|
||||||
|
event_id: 40,
|
||||||
|
event_type: "project_state_changed",
|
||||||
|
occurred_at: "2026-07-27T09:00:00.000Z",
|
||||||
|
state_version: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
config_set_version: 9,
|
||||||
|
entity_ref: "models:current",
|
||||||
|
event_id: 42,
|
||||||
|
event_type: "model_config_changed",
|
||||||
|
occurred_at: "2026-07-27T09:00:01.000Z",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
await page.goto(`${webUrl}/tests/e2e/fixtures/event-sync.html`);
|
||||||
|
await expect(page.locator("#status")).toHaveText("connected");
|
||||||
|
expect(eventHub.subscriberCount).toBe(1);
|
||||||
|
|
||||||
|
eventHub.publish(published[0]);
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => window.wp0EventFixture.timeline.filter((entry) => entry.action === "refetch_entity").length))
|
||||||
|
.toBe(1);
|
||||||
|
|
||||||
|
eventHub.publish(published[1]);
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => window.wp0EventFixture.timeline.filter((entry) => entry.action === "bootstrap").length))
|
||||||
|
.toBe(1);
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => window.wp0EventFixture.timeline.filter((entry) => entry.action === "refetch_models").length))
|
||||||
|
.toBe(1);
|
||||||
|
|
||||||
|
eventHub.disconnectAll();
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => window.wp0EventFixture.timeline.filter((entry) => entry.action === "bootstrap").length))
|
||||||
|
.toBe(2);
|
||||||
|
|
||||||
|
const timeline = await page.evaluate(() => window.wp0EventFixture.timeline);
|
||||||
|
const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_EVT;
|
||||||
|
if (evidenceDirectory) {
|
||||||
|
mkdirSync(evidenceDirectory, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
resolve(evidenceDirectory, "sse-events.json"),
|
||||||
|
`${JSON.stringify({ events: published, status: "passed" }, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
resolve(evidenceDirectory, "network-timeline.json"),
|
||||||
|
`${JSON.stringify({ status: "passed", timeline }, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>WP0-02 event sync fixture</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main id="status">starting</main>
|
||||||
|
<script type="module" src="/tests/e2e/fixtures/event-sync.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { connectEventSource, createEventSyncController } from "../../../apps/web/src/event-sync.js";
|
||||||
|
|
||||||
|
interface TimelineEntry {
|
||||||
|
action: string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
wp0EventFixture: {
|
||||||
|
timeline: TimelineEntry[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeline: TimelineEntry[] = [];
|
||||||
|
const request = async (url: string, action: string, detail?: string) => {
|
||||||
|
timeline.push({ action, ...(detail ? { detail } : {}) });
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) throw new Error(`${action} failed`);
|
||||||
|
await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const controller = createEventSyncController({
|
||||||
|
bootstrap: () => request("/api/v1/bootstrap", "bootstrap"),
|
||||||
|
refetchEntity: (entityRef) =>
|
||||||
|
request(`/__test/entity?ref=${encodeURIComponent(entityRef)}`, "refetch_entity", entityRef),
|
||||||
|
refetchModels: (input) =>
|
||||||
|
request("/__test/models", "refetch_models", input.reason),
|
||||||
|
});
|
||||||
|
const source = connectEventSource("/api/v1/events", controller);
|
||||||
|
source.addEventListener("open", () => {
|
||||||
|
timeline.push({ action: "connected" });
|
||||||
|
document.getElementById("status")!.textContent = "connected";
|
||||||
|
});
|
||||||
|
|
||||||
|
window.wp0EventFixture = { timeline };
|
||||||
@@ -20,7 +20,7 @@ describe("TASK-WP0-01 minimum toolchain", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("loads and closes Fastify with the frozen Swagger plugin", async () => {
|
it("loads and closes Fastify with the frozen Swagger plugin", async () => {
|
||||||
const app = createApp();
|
const app = await createApp();
|
||||||
await app.ready();
|
await app.ready();
|
||||||
expect(app.hasPlugin("@fastify/swagger")).toBe(true);
|
expect(app.hasPlugin("@fastify/swagger")).toBe(true);
|
||||||
await app.close();
|
await app.close();
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createEventSyncController,
|
||||||
|
type EventSyncDependencies,
|
||||||
|
} from "../../apps/web/src/event-sync.js";
|
||||||
|
import { isSseEvent } from "../../packages/shared-contracts/src/index.js";
|
||||||
|
|
||||||
|
function dependencies() {
|
||||||
|
return {
|
||||||
|
bootstrap: vi.fn(async () => undefined),
|
||||||
|
refetchEntity: vi.fn(async () => undefined),
|
||||||
|
refetchModels: vi.fn(async () => undefined),
|
||||||
|
} satisfies EventSyncDependencies;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TDD-WP0-EVT-001 REST refetch", () => {
|
||||||
|
it("accepts only fixed non-sensitive event fields and separated versions", () => {
|
||||||
|
expect(
|
||||||
|
isSseEvent({
|
||||||
|
config_set_version: 7,
|
||||||
|
entity_ref: "models:current",
|
||||||
|
event_id: 1,
|
||||||
|
event_type: "model_config_changed",
|
||||||
|
occurred_at: "2026-07-27T09:00:00.000Z",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isSseEvent({
|
||||||
|
entity_ref: "models:runtime",
|
||||||
|
event_id: 2,
|
||||||
|
event_type: "model_runtime_changed",
|
||||||
|
occurred_at: "2026-07-27T09:00:01.000Z",
|
||||||
|
runtime_availability_version: 12,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isSseEvent({
|
||||||
|
config_set_version: 7,
|
||||||
|
entity_ref: "models:current",
|
||||||
|
event_id: 3,
|
||||||
|
event_type: "model_changed",
|
||||||
|
occurred_at: "2026-07-27T09:00:02.000Z",
|
||||||
|
prompt: "private body",
|
||||||
|
runtime_availability_version: 12,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses REST for every event and bootstraps once after a sequence gap", async () => {
|
||||||
|
const calls = dependencies();
|
||||||
|
const controller = createEventSyncController(calls);
|
||||||
|
|
||||||
|
await controller.handleEvent({
|
||||||
|
entity_ref: "projects:project-1",
|
||||||
|
event_id: 10,
|
||||||
|
event_type: "project_state_changed",
|
||||||
|
occurred_at: "2026-07-27T09:00:00.000Z",
|
||||||
|
state_version: 4,
|
||||||
|
});
|
||||||
|
await controller.handleEvent({
|
||||||
|
config_set_version: 8,
|
||||||
|
entity_ref: "models:current",
|
||||||
|
event_id: 12,
|
||||||
|
event_type: "model_config_changed",
|
||||||
|
occurred_at: "2026-07-27T09:00:01.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(calls.refetchEntity).toHaveBeenCalledWith("projects:project-1");
|
||||||
|
expect(calls.bootstrap).toHaveBeenCalledTimes(1);
|
||||||
|
expect(calls.refetchModels).toHaveBeenCalledWith({
|
||||||
|
configSetVersion: 8,
|
||||||
|
reason: "config",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bootstraps once per disconnect incident and never applies event payload as truth", async () => {
|
||||||
|
const calls = dependencies();
|
||||||
|
const controller = createEventSyncController(calls);
|
||||||
|
|
||||||
|
await controller.handleDisconnect();
|
||||||
|
await controller.handleDisconnect();
|
||||||
|
expect(calls.bootstrap).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await controller.handleEvent({
|
||||||
|
entity_ref: "storage:local",
|
||||||
|
event_id: 21,
|
||||||
|
event_type: "storage_state_changed",
|
||||||
|
occurred_at: "2026-07-27T09:00:02.000Z",
|
||||||
|
state_version: 9,
|
||||||
|
});
|
||||||
|
await controller.handleDisconnect();
|
||||||
|
|
||||||
|
expect(calls.bootstrap).toHaveBeenCalledTimes(2);
|
||||||
|
expect(calls.refetchEntity).toHaveBeenCalledWith("storage:local");
|
||||||
|
expect(calls.refetchEntity).not.toHaveBeenCalledWith(expect.objectContaining({ state_version: 9 }));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user