/** * `pkg.run(resource, action, input, context?)` — one action, in-process, no * platform. The SDK builds the notification the platform would send, runs the * same dispatch production uses, and hands back what the platform would have * recorded: status, results, the error code, the log lines, the steps and the * traffic the handler reported. `pkg.run(event, payload)` does the same for * an event, `pkg.execute(document)` for the execute component. */ import { createApiClient } from "../api/client"; import { InMemoryPlatformClient } from "../platform"; import type { Notification } from "../scope/index"; import { silentNarrative } from "../scope/narrative"; import type { EventComponent } from "./components"; import { dispatch, type Dispatchable, type Document } from "./dispatch"; import type { Resource } from "./resource"; export interface RunContext { /** Aborted to simulate the worker being stopped mid-action. */ signal?: AbortSignal; /** The service the action belongs to, as the platform would send it (id, name, nrn, attributes, dimensions…). */ service?: Record; /** The link, for link actions. */ link?: Record; /** The scope, for scope actions. */ scope?: Record; /** Anything else the notification should carry (entity_nrn, user, …). */ notification?: Record; /** Provider attributes by category, what `ctx.settings.providers()` answers. */ providers?: Record>; /** NRN parameters, what `ctx.settings.parameters()` answers. */ parameters?: Record; /** What `ctx.settings.buildContext()` answers (the CLI's build-context shape). */ context?: Record; /** Answers `ctx.api` calls; default: `200 {}`. Every call is recorded in `requests` either way. */ fetch?: (request: Request) => Response | Promise; } /** One `ctx.api` call a run recorded, never sent. */ export interface RunRequest { method: string; /** Path and query, relative to the API root. */ path: string; body?: unknown; } export interface RunStep { key: string; status: "completed" | "failed"; ms: number; } export interface RunResult { /** Traffic weights the handler reported through ctx.deployment.reportTraffic. */ traffic: number[]; /** The `ctx.api` calls the handler made, recorded, never sent. */ requests: RunRequest[]; status: "success" | "failed"; results?: Record; error?: { code: string; message: string; retryable: boolean; details?: Record; }; data?: unknown; /** True for a data action (log:read, instance:data): no lifecycle, `data` is what the worker prints. */ raw?: boolean; logs: string[]; steps: RunStep[]; } // eslint-disable-next-line no-control-regex const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, ""); export function buildNotification( res: Resource, action: string, input: Record, ctx: RunContext ): Notification { const isLink = res.kind === "link_specification"; const spec = isLink ? res.parent : res; const isDefault = action === "create" || action === "update" || action === "delete"; const isScope = (spec?.body as { type?: string } | undefined)?.type === "scope"; const service = { id: "svc-test", slug: (ctx.service?.slug as string) ?? "test-service", attributes: {}, dimensions: {}, ...(ctx.service ?? {}), specification: { id: `${spec?.slug}-spec`, slug: spec?.slug ?? "", ...((ctx.service?.specification as object) ?? {}), }, }; const link = isLink ? { id: "lnk-test", attributes: {}, ...(ctx.link ?? {}), specification: { id: `${res.slug}-link-spec`, slug: res.slug, ...((ctx.link?.specification as object) ?? {}), }, } : null; return { id: "action-test", slug: isDefault ? `${action}-${spec?.slug}` : action, action: isScope && action.includes(":") ? action : `service:action:${isDefault ? action : "custom"}`, type: isDefault ? action : "custom", name: action, status: "pending", parameters: input, tags: {}, entity_nrn: (ctx.notification?.entity_nrn as string | undefined) ?? (ctx.service?.nrn as string | undefined) ?? "organization=1:account=2:namespace=3:application=4", specification: { id: "action-spec-test", slug: action }, service, results: {}, user: { id: 1, email: "test@nullplatform.io" }, link, ...(ctx.scope ? { scope: ctx.scope } : {}), ...(ctx.notification ?? {}), } as unknown as Notification; } /** The notification the notifications API would deliver for an event. */ export function buildEventNotification( ev: EventComponent, payload: Record, ctx: RunContext ): Record { return { id: "event-test", nrn: (ctx.notification?.nrn as string | undefined) ?? "organization=1:account=2:namespace=3:application=4", source: ev.source, action: ev.name, context: payload, ...(ctx.notification ?? {}), }; } export async function runAction( pkg: Dispatchable, res: Resource, action: string, input: Record = {}, ctx: RunContext = {} ): Promise { const notification = buildNotification(res, action, input, ctx); return runDocument( pkg, { doc: { notification }, raw: JSON.stringify({ notification }) }, ctx ); } export async function runEvent( pkg: Dispatchable, ev: EventComponent, payload: Record = {}, ctx: RunContext = {} ): Promise { const notification = buildEventNotification(ev, payload, ctx); return runDocument( pkg, { doc: { notification }, raw: JSON.stringify({ notification }) }, ctx ); } export async function runExecute( pkg: Dispatchable, document: unknown, ctx: RunContext = {} ): Promise { const raw = typeof document === "string" ? document : JSON.stringify(document ?? null); let doc: unknown = document; if (typeof document === "string") { try { doc = JSON.parse(document); } catch { doc = document; } } return runDocument(pkg, { doc, raw }, ctx); } async function runDocument( pkg: Dispatchable, document: Document, ctx: RunContext ): Promise { const platform = new InMemoryPlatformClient({ echo: false }); const logs: string[] = []; const steps: RunStep[] = []; const traffic: number[] = []; const requests: RunRequest[] = []; const api = createApiClient({ baseUrl: "https://api.test", token: async () => "test-token", fetch: (async (request: Request) => { const url = new URL(request.url); const text = await request.clone().text(); requests.push({ method: request.method, path: url.pathname + url.search, ...(text ? { body: safeJson(text) } : {}), }); return ctx.fetch ? ctx.fetch(request) : new Response("{}", { status: 200, headers: { "Content-Type": "application/json" }, }); }) as typeof fetch, }); const providers = ctx.providers ?? {}; const parameters = ctx.parameters ?? {}; const emit = (o: { stdout?: string; stderr?: string }) => { const line = o.stdout ?? o.stderr; if (line) logs.push(stripAnsi(line).trimEnd()); }; const outcome = await dispatch(pkg, document, { platform, emit, package: { slug: pkg.slug ?? "", version: pkg.version ?? "" }, signal: ctx.signal, // Settings and the deployment write answer from the test's own data, never from the API. api, settings: { providers: async (categories: string[]) => Object.fromEntries( categories.filter((k) => k in providers).map((k) => [k, providers[k]]) ), parameters: async () => parameters, buildContext: async () => ctx.context ?? {}, }, deployment: { reportTraffic: async (weight: number) => { traffic.push(weight); }, }, onStep: async (name, run) => { const started = Date.now(); try { const out = await run(silentNarrative); steps.push({ key: name, status: "completed", ms: Date.now() - started, }); return out; } catch (err) { steps.push({ key: name, status: "failed", ms: Date.now() - started }); throw err; } }, }); return { ...outcome, logs, steps, traffic, requests }; } function safeJson(text: string): unknown { try { return JSON.parse(text); } catch { return text; } }