From 9567e67b6a338b8a2e71a59da1f218e06bece0c0 Mon Sep 17 00:00:00 2001 From: Michael Hobbs Date: Tue, 14 Jul 2026 22:12:51 -0400 Subject: [PATCH 01/11] test(engine): real-message E2E harness + flagship cascade tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a reusable harness that actually sends messages through channels instead of just executing scripts in isolation: - e2e-harness.ts: deployChannel(spec) assembles a live channel (sandbox + 8-stage pipeline + real source/dest connectors) over an in-memory store. Source/destination filter+transformer+responseTransformer scripts are written as TS/JS and compiled through the real esbuild compiler with code-template injection — the same path production uses. Includes an in-memory CaptureDestination sink and a full MessageStore implementation. - tcp-helpers.ts: real MLLP capture server + client for TCP connector tests. - real-messaging.e2e.test.ts: 1. TCP/MLLP in -> TS source transformer calling a FUNCTION code template -> real TCP/MLLP out; asserts the transformed wire output + RAW/SENT rows. 2. A -> B -> C cascade over the in-memory Channel connector converting the payload at each hop (HL7 -> JSON -> XML); asserts the final delivered XML and per-channel message records. Fixes discovered while wiring the harness: - prependTemplates takes a script-key context (sourceTransformer, etc.), not the mapped template-context value; passing the wrong string silently skipped injection. - onMessage must map the pipeline SENT status to the source-dispatch PROCESSED status (mirrors the production engine's toDispatchStatus). Engine suite: 374 passing (+2), build + lint clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01F1ad6jb5mCYxzFVXHbksqi --- .../src/__tests__/real-messaging.e2e.test.ts | 202 ++++++++++ .../src/__tests__/support/e2e-harness.ts | 375 ++++++++++++++++++ .../src/__tests__/support/tcp-helpers.ts | 68 ++++ 3 files changed, 645 insertions(+) create mode 100644 packages/engine/src/__tests__/real-messaging.e2e.test.ts create mode 100644 packages/engine/src/__tests__/support/e2e-harness.ts create mode 100644 packages/engine/src/__tests__/support/tcp-helpers.ts diff --git a/packages/engine/src/__tests__/real-messaging.e2e.test.ts b/packages/engine/src/__tests__/real-messaging.e2e.test.ts new file mode 100644 index 0000000..8426298 --- /dev/null +++ b/packages/engine/src/__tests__/real-messaging.e2e.test.ts @@ -0,0 +1,202 @@ +// =========================================== +// Real-Messaging E2E: send messages THROUGH channels +// =========================================== +// These tests do what unit tests can't: push a real message in through a real +// source connector, let it run the full 8-stage pipeline (with real esbuild- +// compiled TS scripts + injected code templates), and assert on the transformed +// output that a real destination connector received — plus the persisted rows. +// +// Test 1 TCP/MLLP in → TS source transformer (calls a FUNCTION code template) +// → TCP/MLLP out. Asserts the wire output + RAW/SENT content rows. +// +// Test 2 Cascade A → B → C over the in-memory Channel connector, converting +// the payload at each hop (HL7 → JSON → XML), proving multi-channel +// routing + per-channel transformation. This is the "silly but good" +// cascade from the project vision. + +import { describe, it, expect, afterEach } from 'vitest'; +import { CONTENT_TYPE } from '@mirthless/core-models'; +import { + TcpMllpReceiver, + TcpMllpDispatcher, + ChannelReceiver, + ChannelDispatcher, + clearChannelRegistry, +} from '@mirthless/connectors'; +import type { CodeTemplateData } from '../index.js'; +import { + deployChannel, + teardownAll, + CaptureDestination, + type DeployedChannel, +} from './support/e2e-harness.js'; +import { startMllpCaptureServer, sendMllp, type MllpCaptureServer } from './support/tcp-helpers.js'; + +// ----- Fixtures ----- + +const HL7_ADT = [ + 'MSH|^~\\&|SENDER|FACILITY|RECEIVER|FACILITY|20260228120000||ADT^A01|12345|P|2.5', + 'EVN|A01|20260228120000', + 'PID|||12345^^^MRN||DOE^JOHN||19800101|M', + 'PV1||I|ICU^101^A', +].join('\r'); + +// A FUNCTION code template, authored in TypeScript, injected into the source +// transformer's scope. Exercises the template-injection + TS-compile path. +const REDACT_TEMPLATE: CodeTemplateData = { + type: 'FUNCTION', + contexts: ['SOURCE_FILTER_TRANSFORMER'], + code: [ + '/** Redact the MRN in a raw HL7 v2 message. */', + 'function redactMrn(hl7: string): string {', + " return hl7.split('12345^^^MRN').join('REDACTED^^^MRN');", + '}', + ].join('\n'), +}; + +const SRC_PORT_1 = 17681; +const DEST_PORT_1 = 17682; +const SRC_PORT_2 = 17683; + +let deployed: DeployedChannel[] = []; +let captureServer: MllpCaptureServer | null = null; + +afterEach(async () => { + await teardownAll(deployed); + deployed = []; + if (captureServer) { captureServer.close(); captureServer = null; } + clearChannelRegistry(); +}); + +// ----- Test 1: real TCP in → TS+template transform → real TCP out ----- + +describe('real message through a single channel (TCP → transform → TCP)', () => { + it('applies a TS transformer using a code template and delivers to a real TCP destination', async () => { + captureServer = await startMllpCaptureServer(DEST_PORT_1); + + const channel = await deployChannel({ + channelId: '00000000-0000-0000-0000-realmsg00001', + dataType: 'HL7V2', + source: new TcpMllpReceiver({ host: '127.0.0.1', port: SRC_PORT_1, maxConnections: 10 }), + templates: [REDACT_TEMPLATE], + // TS source transformer: uses the injected template, then appends a segment. + transformer: [ + 'const out: string = redactMrn(String(msg));', + "return out + '\\rZZZ|processed';", + ].join('\n'), + destinations: [{ + metaDataId: 1, + name: 'TCP Out', + connector: new TcpMllpDispatcher({ + host: '127.0.0.1', port: DEST_PORT_1, maxConnections: 5, responseTimeout: 5_000, + }), + }], + }); + deployed.push(channel); + + const ack = await sendMllp(SRC_PORT_1, HL7_ADT); + expect(ack).toContain('MSH'); + + // The destination server received the TRANSFORMED message off the wire. + // Allow the async dispatch to settle. + await viWaitFor(() => captureServer!.received.length === 1); + const delivered = captureServer.received[0] ?? ''; + expect(delivered).toContain('REDACTED^^^MRN'); + expect(delivered).toContain('ZZZ|processed'); + expect(delivered).not.toContain('12345^^^MRN'); + + // Persisted rows: RAW = original inbound, SENT = transformed outbound. + expect(channel.store.messageCount()).toBe(1); + const raw = channel.store.contentOf(1, 0, CONTENT_TYPE.RAW); + expect(raw).toContain('12345^^^MRN'); + const sent = channel.store.contentOf(1, 1, CONTENT_TYPE.SENT); + expect(sent).toContain('ZZZ|processed'); + expect(sent).toContain('REDACTED^^^MRN'); + }); +}); + +// ----- Test 2: A → B → C cascade with per-hop conversion ----- + +describe('cascade across channels (A → B → C), converting the payload at each hop', () => { + it('routes HL7 into A, converts to JSON in B, to XML in C, delivering the final XML', async () => { + const CH_A = '00000000-0000-0000-0000-cascade0000a'; + const CH_B = '00000000-0000-0000-0000-cascade0000b'; + const CH_C = '00000000-0000-0000-0000-cascade0000c'; + + const sink = new CaptureDestination(); + + // Deploy C first so its ChannelReceiver is registered before B routes to it, + // and B before A. (Registration happens on start, which deployChannel does.) + const channelC = await deployChannel({ + channelId: CH_C, + // RAW: msg stays the raw string so string-based XML editing is deterministic. + dataType: 'RAW', + source: new ChannelReceiver({ channelId: CH_C }), + // C: tag the XML as having passed through C. + transformer: "return String(msg).replace('', 'C');", + destinations: [{ metaDataId: 1, name: 'Sink', connector: sink }], + }); + + const channelB = await deployChannel({ + channelId: CH_B, + // RAW: receive A's JSON string verbatim so JSON.parse sees a string. + dataType: 'RAW', + source: new ChannelReceiver({ channelId: CH_B }), + // B: JSON → XML. + transformer: [ + 'const o = JSON.parse(String(msg));', + "return '' + o.name + '' + o.source + '';", + ].join('\n'), + destinations: [{ + metaDataId: 1, + name: 'To C', + dataType: 'XML', + connector: new ChannelDispatcher({ targetChannelId: CH_C, waitForResponse: true }), + }], + }); + + const channelA = await deployChannel({ + channelId: CH_A, + dataType: 'HL7V2', + source: new TcpMllpReceiver({ host: '127.0.0.1', port: SRC_PORT_2, maxConnections: 10 }), + // A: HL7 → JSON (pull PID-5 patient name). + transformer: [ + 'const lines = String(msg).split(String.fromCharCode(13));', + "const pid = lines.find((l) => l.indexOf('PID') === 0) || '';", + "const name = pid.split('|')[5] || '';", + "return JSON.stringify({ name: name, source: 'A' });", + ].join('\n'), + destinations: [{ + metaDataId: 1, + name: 'To B', + dataType: 'JSON', + connector: new ChannelDispatcher({ targetChannelId: CH_B, waitForResponse: true }), + }], + }); + deployed.push(channelA, channelB, channelC); + + await sendMllp(SRC_PORT_2, HL7_ADT); + await viWaitFor(() => sink.received.length === 1); + + const finalXml = sink.lastContent() ?? ''; + expect(finalXml).toContain('DOE^JOHN'); + expect(finalXml).toContain('A'); + expect(finalXml).toContain('C'); + + // Each channel processed exactly one message. + expect(channelA.store.messageCount()).toBe(1); + expect(channelB.store.messageCount()).toBe(1); + expect(channelC.store.messageCount()).toBe(1); + }); +}); + +// ----- small polling helper (avoids a fixed sleep for async dispatch) ----- + +async function viWaitFor(predicate: () => boolean, timeoutMs = 5_000): Promise { + const start = Date.now(); + for (;;) { + if (predicate()) return; + if (Date.now() - start > timeoutMs) throw new Error('viWaitFor: condition not met in time'); + await new Promise((r) => setTimeout(r, 10)); + } +} diff --git a/packages/engine/src/__tests__/support/e2e-harness.ts b/packages/engine/src/__tests__/support/e2e-harness.ts new file mode 100644 index 0000000..17c3f18 --- /dev/null +++ b/packages/engine/src/__tests__/support/e2e-harness.ts @@ -0,0 +1,375 @@ +// =========================================== +// E2E Channel Harness +// =========================================== +// Reusable support for tests that ACTUALLY send messages through channels. +// Assembles a real channel runtime (sandbox + 8-stage pipeline + real source +// and destination connectors) backed by an in-memory message store, so a test +// can: build a channel from a declarative spec, push a message in through the +// source connector, and assert on the transformed output + persisted rows. +// +// Scripts (source filter/transformer, per-destination filter/transformer/ +// response-transformer) are written as TS/JS source and compiled through the +// real esbuild-backed compiler with code-template injection — exercising the +// same path production uses. No mocking of anything we own. + +import { + VmSandboxExecutor, + MessageProcessor, + ChannelRuntime, + DEFAULT_EXECUTION_OPTIONS, + compileScript, + prependTemplates, +} from '../../index.js'; +import type { + MessageStore, + PipelineConfig, + DestinationConfig, + DestinationScripts, + ChannelScripts, + ChannelRuntimeConfig, + SendToDestination, + DestinationResponse, + CompiledScript, + CodeTemplateData, +} from '../../index.js'; +import type { + SourceConnectorRuntime, + DestinationConnectorRuntime, + ConnectorMessage, + ConnectorResponse, +} from '@mirthless/connectors'; +import type { Result } from '@mirthless/core-util'; + +// ----- Result helper ----- + +function ok(value: T): Result { + return { ok: true, value, error: null } as Result; +} + +// ----- In-memory message store ----- + +export interface StoredContentRow { + readonly channelId: string; + readonly messageId: number; + readonly metaDataId: number; + readonly contentType: number; + readonly content: string; + readonly dataType: string; +} + +export interface StoredConnectorMessageRow { + readonly channelId: string; + readonly messageId: number; + readonly metaDataId: number; + readonly connectorName: string; + status: string; +} + +export interface StoredStatRow { + readonly channelId: string; + readonly metaDataId: number; + received: number; + filtered: number; + sent: number; + errored: number; +} + +export interface InMemoryStore extends MessageStore { + readonly contents: readonly StoredContentRow[]; + readonly connectorMessages: readonly StoredConnectorMessageRow[]; + readonly stats: readonly StoredStatRow[]; + /** Number of source messages created. */ + messageCount(): number; + /** Content of a specific stored row, or null. */ + contentOf(messageId: number, metaDataId: number, contentType: number): string | null; +} + +/** Build a fresh in-memory store fully satisfying the MessageStore contract. */ +export function createInMemoryStore(): InMemoryStore { + let nextMessageId = 1; + const messages: { channelId: string; messageId: number; processed: boolean }[] = []; + const connectorMessages: StoredConnectorMessageRow[] = []; + const contents: StoredContentRow[] = []; + const stats: StoredStatRow[] = []; + + return { + contents, + connectorMessages, + stats, + messageCount: () => messages.length, + contentOf: (messageId, metaDataId, contentType) => + contents.find( + (c) => c.messageId === messageId && c.metaDataId === metaDataId && c.contentType === contentType, + )?.content ?? null, + + createMessage: async (channelId, _serverId, correlationId) => { + const messageId = nextMessageId++; + messages.push({ channelId, messageId, processed: false }); + return ok({ messageId, correlationId: correlationId ?? `corr-${channelId}-${String(messageId)}` }); + }, + createConnectorMessage: async (channelId, messageId, metaDataId, name, status) => { + connectorMessages.push({ channelId, messageId, metaDataId, connectorName: name, status }); + return ok(undefined); + }, + updateConnectorMessageStatus: async (channelId, messageId, metaDataId, status) => { + const cm = connectorMessages.find( + (m) => m.channelId === channelId && m.messageId === messageId && m.metaDataId === metaDataId, + ); + if (cm) cm.status = status; + return ok(undefined); + }, + storeContent: async (channelId, messageId, metaDataId, contentType, content, dataType) => { + contents.push({ channelId, messageId, metaDataId, contentType, content, dataType }); + return ok(undefined); + }, + markProcessed: async (channelId, messageId) => { + const msg = messages.find((m) => m.channelId === channelId && m.messageId === messageId); + if (msg) msg.processed = true; + return ok(undefined); + }, + enqueue: async () => ok(undefined), + loadContent: async (channelId, messageId, metaDataId, contentType) => { + const entry = contents.find( + (c) => c.channelId === channelId && c.messageId === messageId + && c.metaDataId === metaDataId && c.contentType === contentType, + ); + return ok(entry?.content ?? null); + }, + dequeue: async () => ok([]), + release: async () => ok(undefined), + incrementStats: async (channelId, metaDataId, _serverId, field) => { + let stat = stats.find((s) => s.channelId === channelId && s.metaDataId === metaDataId); + if (!stat) { + stat = { channelId, metaDataId, received: 0, filtered: 0, sent: 0, errored: 0 }; + stats.push(stat); + } + stat[field] += 1; + return ok(undefined); + }, + }; +} + +// ----- Channel spec ----- + +export interface DestinationSpec { + readonly metaDataId: number; + readonly name: string; + readonly connector: DestinationConnectorRuntime; + /** dataType tagged on the outbound ConnectorMessage; defaults to channel dataType. */ + readonly dataType?: string; + readonly filter?: string; + readonly transformer?: string; + readonly responseTransformer?: string; +} + +export interface ChannelSpec { + readonly channelId: string; + readonly serverId?: string; + readonly dataType: string; + readonly source: SourceConnectorRuntime; + readonly templates?: ReadonlyArray; + /** Channel preprocessor script. */ + readonly preprocessor?: string; + /** Source filter script (`return true` to pass). */ + readonly filter?: string; + /** Source transformer script. */ + readonly transformer?: string; + readonly destinations: ReadonlyArray; +} + +export interface DeployedChannel { + readonly runtime: ChannelRuntime; + readonly store: InMemoryStore; + teardown(): Promise; +} + +async function compile( + code: string, + templates: ReadonlyArray, + context: string, + sourcefile: string, +): Promise { + const withTemplates = prependTemplates(code, templates, context); + const result = await compileScript(withTemplates, { sourcefile }); + if (!result.ok) { + throw new Error(`compile failed for ${sourcefile}: ${result.error.message}`); + } + return result.value; +} + +async function buildDestinationConfigs( + spec: ChannelSpec, + templates: ReadonlyArray, +): Promise { + const configs: DestinationConfig[] = []; + for (const d of spec.destinations) { + const scripts: { + filter?: CompiledScript; + transformer?: CompiledScript; + responseTransformer?: CompiledScript; + } = {}; + const base = `${spec.channelId}/dest-${String(d.metaDataId)}`; + if (d.filter !== undefined) { + scripts.filter = await compile(d.filter, templates, 'destinationFilter', `${base}-filter.ts`); + } + if (d.transformer !== undefined) { + scripts.transformer = await compile(d.transformer, templates, 'destinationTransformer', `${base}-transformer.ts`); + } + if (d.responseTransformer !== undefined) { + // Note: CONTEXT_MAP has no response-transformer key, so template injection + // is a no-op here (templates are unavailable in response transformers). + scripts.responseTransformer = await compile( + d.responseTransformer, templates, 'destinationResponseTransformer', `${base}-response.ts`, + ); + } + configs.push({ + metaDataId: d.metaDataId, + name: d.name, + enabled: true, + scripts: scripts as DestinationScripts, + queueMode: 'NEVER', + }); + } + return configs; +} + +/** + * Assemble, deploy, and start a channel from a declarative spec. + * The returned channel is live: pushing a message through `spec.source` + * runs the full pipeline and dispatches to the real destination connectors. + */ +export async function deployChannel(spec: ChannelSpec): Promise { + const sandbox = new VmSandboxExecutor(); + const store = createInMemoryStore(); + const serverId = spec.serverId ?? 'e2e-server'; + const templates = spec.templates ?? []; + + const sourceScripts: { + preprocessor?: CompiledScript; + sourceFilter?: CompiledScript; + sourceTransformer?: CompiledScript; + } = {}; + if (spec.preprocessor !== undefined) { + sourceScripts.preprocessor = await compile( + spec.preprocessor, templates, 'preprocessor', `${spec.channelId}/preprocessor.ts`, + ); + } + if (spec.filter !== undefined) { + sourceScripts.sourceFilter = await compile( + spec.filter, templates, 'sourceFilter', `${spec.channelId}/source-filter.ts`, + ); + } + if (spec.transformer !== undefined) { + sourceScripts.sourceTransformer = await compile( + spec.transformer, templates, 'sourceTransformer', `${spec.channelId}/source-transformer.ts`, + ); + } + + const destConfigs = await buildDestinationConfigs(spec, templates); + + const pipelineConfig: PipelineConfig = { + channelId: spec.channelId, + serverId, + dataType: spec.dataType, + scripts: sourceScripts as ChannelScripts, + destinations: destConfigs, + }; + + const destByMeta = new Map(spec.destinations.map((d) => [d.metaDataId, d])); + const sendFn: SendToDestination = async (metaDataId, messageId, content, signal) => { + const d = destByMeta.get(metaDataId); + if (!d) { + return ok({ status: 'ERROR', content: '', errorMessage: `unknown destination ${String(metaDataId)}` }); + } + const message: ConnectorMessage = { + channelId: spec.channelId, + messageId, + metaDataId, + content, + dataType: d.dataType ?? spec.dataType, + }; + return d.connector.send(message, signal); + }; + + const processor = new MessageProcessor(sandbox, store, sendFn, pipelineConfig, DEFAULT_EXECUTION_OPTIONS); + + const runtime = new ChannelRuntime(); + const destinations = new Map( + spec.destinations.map((d) => [d.metaDataId, d.connector]), + ); + const runtimeConfig: ChannelRuntimeConfig = { + channelId: spec.channelId, + source: spec.source, + destinations, + onMessage: async (raw) => { + const result = await processor.processMessage( + { rawContent: raw.content, sourceMap: raw.sourceMap }, + AbortSignal.timeout(30_000), + ); + if (!result.ok) return result; + // Map the pipeline's SENT success status to the source-dispatch PROCESSED + // status the runtime/source connectors expect (same as production engine). + const { messageId, status, response } = result.value; + const dispatchStatus = status === 'FILTERED' ? 'FILTERED' : status === 'ERROR' ? 'ERROR' : 'PROCESSED'; + return ok(response !== undefined + ? { messageId, status: dispatchStatus, response } + : { messageId, status: dispatchStatus }); + }, + }; + + const deployResult = await runtime.deploy(runtimeConfig); + if (!deployResult.ok) throw new Error(`deploy failed: ${deployResult.error.message}`); + const startResult = await runtime.start(); + if (!startResult.ok) throw new Error(`start failed: ${startResult.error.message}`); + + return { + runtime, + store, + teardown: async () => { + const state = runtime.getState(); + if (state === 'STARTED' || state === 'PAUSED') await runtime.stop(); + if (runtime.getState() === 'STOPPED') await runtime.undeploy(); + }, + }; +} + +/** Stop and undeploy several channels, ignoring already-stopped ones. */ +export async function teardownAll(channels: readonly DeployedChannel[]): Promise { + for (const c of channels) { + await c.teardown(); + } +} + +// ----- Capture destination (an in-memory sink connector) ----- + +/** + * A real DestinationConnectorRuntime that records every message it is sent. + * Use as the terminal sink of a chain to assert on the final transformed output + * without needing an external server. It IS a real connector (implements the + * full lifecycle + send contract) — the message genuinely flows through the + * pipeline and dispatch path to reach it. + */ +export class CaptureDestination implements DestinationConnectorRuntime { + readonly received: ConnectorMessage[] = []; + private readonly reply: (msg: ConnectorMessage) => string; + + constructor(reply?: (msg: ConnectorMessage) => string) { + this.reply = reply ?? (() => 'ACK'); + } + + /** Content of the most recently received message, or null. */ + lastContent(): string | null { + return this.received.at(-1)?.content ?? null; + } + + async onDeploy(): Promise> { return ok(undefined); } + async onStart(): Promise> { return ok(undefined); } + async onStop(): Promise> { return ok(undefined); } + async onHalt(): Promise> { return ok(undefined); } + async onUndeploy(): Promise> { return ok(undefined); } + + async send(message: ConnectorMessage, _signal: AbortSignal): Promise> { + this.received.push(message); + return ok({ status: 'SENT', content: this.reply(message) }); + } +} diff --git a/packages/engine/src/__tests__/support/tcp-helpers.ts b/packages/engine/src/__tests__/support/tcp-helpers.ts new file mode 100644 index 0000000..0c28582 --- /dev/null +++ b/packages/engine/src/__tests__/support/tcp-helpers.ts @@ -0,0 +1,68 @@ +// =========================================== +// TCP/MLLP Test Helpers +// =========================================== +// Real TCP servers/clients for exercising the TCP/MLLP connectors end to end. + +import * as net from 'node:net'; +import { wrapMllp, MllpParser } from '@mirthless/connectors'; + +export interface MllpCaptureServer { + /** Every framed message the server has received, in order. */ + readonly received: readonly string[]; + readonly port: number; + close(): void; +} + +/** + * Start a TCP server that speaks MLLP: it records each framed message it + * receives and replies with an ACK (customisable via `ackFor`). + */ +export async function startMllpCaptureServer( + port: number, + ackFor?: (message: string) => string, +): Promise { + const received: string[] = []; + const server = net.createServer((socket) => { + const parser = new MllpParser(); + socket.on('data', (chunk: Buffer) => { + for (const message of parser.parse(chunk)) { + received.push(message); + socket.write(wrapMllp(ackFor ? ackFor(message) : 'MSH|^~\\&|ACK|||AA')); + } + }); + socket.on('error', () => { /* client reset — ignore in tests */ }); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', () => { resolve(); }); + }); + + return { received, port, close: () => { server.close(); } }; +} + +/** Connect, send one MLLP-framed message, and resolve with the framed reply. */ +export async function sendMllp(port: number, message: string, timeoutMs = 10_000): Promise { + return new Promise((resolve, reject) => { + const client = net.createConnection({ host: '127.0.0.1', port }, () => { + client.write(wrapMllp(message)); + }); + + const parser = new MllpParser(); + client.on('data', (chunk: Buffer) => { + const messages = parser.parse(chunk); + const first = messages[0]; + if (first !== undefined) { + client.end(); + resolve(first); + } + }); + + client.on('error', reject); + const timer = setTimeout(() => { + client.destroy(); + reject(new Error('sendMllp: timed out waiting for reply')); + }, timeoutMs); + timer.unref(); + }); +} From d7384ea227a9428dd775e479f846586a6532b1c3 Mon Sep 17 00:00:00 2001 From: Michael Hobbs Date: Tue, 14 Jul 2026 22:16:04 -0400 Subject: [PATCH 02/11] test(engine): real File + HTTP connector E2E via the harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the connector matrix with two more real-I/O connectors, each actually used end to end: - File: drop a *.hl7 into a temp source dir → File source polls it up → transform → File destination writes ${messageId}.out to a temp dir. Asserts the written file content and that the source consumed the input. - HTTP: POST to a real HttpReceiver → transform → HttpDispatcher POSTs to a downstream http.Server. Asserts the body that landed downstream. Proves the harness generalises past TCP/Channel to polling and request/ response sources. Connectors now covered by real-message E2E: TCP/MLLP, Channel, File, HTTP. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01F1ad6jb5mCYxzFVXHbksqi --- .../__tests__/connector-matrix.e2e.test.ts | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 packages/engine/src/__tests__/connector-matrix.e2e.test.ts diff --git a/packages/engine/src/__tests__/connector-matrix.e2e.test.ts b/packages/engine/src/__tests__/connector-matrix.e2e.test.ts new file mode 100644 index 0000000..b0d5255 --- /dev/null +++ b/packages/engine/src/__tests__/connector-matrix.e2e.test.ts @@ -0,0 +1,190 @@ +// =========================================== +// Connector Matrix E2E: exercise each connector by USING it +// =========================================== +// One real message pushed through a real source connector, transformed, and +// delivered through a real destination connector — asserting the payload that +// actually landed on the other side (a file on disk, an HTTP body downstream). +// +// TCP/MLLP + Channel connectors are covered in real-messaging.e2e.test.ts. +// This file adds File and HTTP. Each new connector added here is a small, +// self-contained block following the same shape. + +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as http from 'node:http'; +import { + FileReceiver, + FileDispatcher, + FILE_SORT_BY, + FILE_POST_ACTION, + HttpReceiver, + HttpDispatcher, +} from '@mirthless/connectors'; +import { deployChannel, teardownAll, type DeployedChannel } from './support/e2e-harness.js'; + +let deployed: DeployedChannel[] = []; +const tempDirs: string[] = []; +const servers: http.Server[] = []; + +afterEach(async () => { + await teardownAll(deployed); + deployed = []; + for (const s of servers.splice(0)) await new Promise((r) => s.close(() => { r(); })); + for (const d of tempDirs.splice(0)) await fs.rm(d, { recursive: true, force: true }); +}); + +// ----- File connector ----- + +describe('File connector (drop a file in → transform → write a file out)', () => { + it('picks up a dropped file, transforms it, and writes the result to the destination dir', async () => { + const srcDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mless-file-src-')); + const destDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mless-file-dst-')); + tempDirs.push(srcDir, destDir); + + const channel = await deployChannel({ + channelId: '00000000-0000-0000-0000-connfile0001', + dataType: 'RAW', + source: new FileReceiver({ + directory: srcDir, + fileFilter: '*.hl7', + pollingIntervalMs: 100, + sortBy: FILE_SORT_BY.NAME, + charset: 'utf-8', + binary: false, + checkFileAge: false, + fileAgeMs: 0, + postAction: FILE_POST_ACTION.DELETE, + moveToDirectory: '', + }), + transformer: "return String(msg) + '::processed';", + destinations: [{ + metaDataId: 1, + name: 'File Out', + connector: new FileDispatcher({ + directory: destDir, + outputPattern: '${messageId}.out', + charset: 'utf-8', + binary: false, + tempFileEnabled: false, + appendMode: false, + }), + }], + }); + deployed.push(channel); + + await fs.writeFile(path.join(srcDir, 'input.hl7'), 'MSH|^~\\&|FILE|IN'); + + const outPath = path.join(destDir, '1.out'); + await waitForAsync(async () => { + try { await fs.access(outPath); return true; } catch { return false; } + }); + + const written = await fs.readFile(outPath, 'utf-8'); + expect(written).toBe('MSH|^~\\&|FILE|IN::processed'); + expect(channel.store.messageCount()).toBe(1); + + // Source consumed the file (postAction DELETE). + const remaining = await fs.readdir(srcDir); + expect(remaining).not.toContain('input.hl7'); + }, 15_000); +}); + +// ----- HTTP connector ----- + +describe('HTTP connector (POST in → transform → POST out to a downstream server)', () => { + it('receives an HTTP POST, transforms the body, and delivers it to a downstream HTTP server', async () => { + const received: string[] = []; + const downstream = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += String(chunk); }); + req.on('end', () => { received.push(body); res.writeHead(200); res.end('OK'); }); + }); + await listen(downstream, DOWNSTREAM_PORT); + servers.push(downstream); + + const channel = await deployChannel({ + channelId: '00000000-0000-0000-0000-connhttp0001', + dataType: 'RAW', + source: new HttpReceiver({ + host: '127.0.0.1', + port: HTTP_SRC_PORT, + path: '/in', + method: 'POST', + responseContentType: 'text/plain', + responseStatusCode: 200, + errorStatusCode: 500, + maxBodyBytes: 1_000_000, + }), + transformer: 'return String(msg).toUpperCase();', + destinations: [{ + metaDataId: 1, + name: 'HTTP Out', + connector: new HttpDispatcher({ + url: `http://127.0.0.1:${String(DOWNSTREAM_PORT)}/out`, + method: 'POST', + headers: {}, + contentType: 'text/plain', + responseTimeout: 5_000, + }), + }], + }); + deployed.push(channel); + + const response = await httpPost(HTTP_SRC_PORT, '/in', 'hello world'); + expect(response.status).toBe(200); + + await waitForSync(() => received.length === 1); + expect(received[0]).toBe('HELLO WORLD'); + expect(channel.store.messageCount()).toBe(1); + }, 15_000); +}); + +// ----- ports + helpers ----- + +const HTTP_SRC_PORT = 17701; +const DOWNSTREAM_PORT = 17702; + +async function listen(server: http.Server, port: number): Promise { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', () => { resolve(); }); + }); +} + +interface HttpResult { readonly status: number; readonly body: string; } + +async function httpPost(port: number, urlPath: string, body: string): Promise { + return new Promise((resolve, reject) => { + const req = http.request( + { host: '127.0.0.1', port, path: urlPath, method: 'POST', headers: { 'Content-Type': 'text/plain', 'Content-Length': Buffer.byteLength(body) } }, + (res) => { + let data = ''; + res.on('data', (chunk) => { data += String(chunk); }); + res.on('end', () => { resolve({ status: res.statusCode ?? 0, body: data }); }); + }, + ); + req.on('error', reject); + req.write(body); + req.end(); + }); +} + +async function waitForSync(predicate: () => boolean, timeoutMs = 10_000): Promise { + const start = Date.now(); + for (;;) { + if (predicate()) return; + if (Date.now() - start > timeoutMs) throw new Error('waitForSync: condition not met in time'); + await new Promise((r) => setTimeout(r, 10)); + } +} + +async function waitForAsync(predicate: () => Promise, timeoutMs = 10_000): Promise { + const start = Date.now(); + for (;;) { + if (await predicate()) return; + if (Date.now() - start > timeoutMs) throw new Error('waitForAsync: condition not met in time'); + await new Promise((r) => setTimeout(r, 20)); + } +} From 4c6af8f2e0d03f5a480a31739afe78c2f5729f95 Mon Sep 17 00:00:00 2001 From: Michael Hobbs Date: Tue, 14 Jul 2026 22:36:20 -0400 Subject: [PATCH 03/11] fix(server): persist filters + transformers on channel CREATE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChannelService.create only wrote channel/scripts/destinations/metadata rows — input.filters and input.transformers were silently dropped, so a create carrying them (clone, import, or a direct API create) lost the channel's source filter and transformer. update already persisted them; create did not. - Extract the filter/transformer delete-and-reinsert logic from update into two shared helpers (syncFilters, syncTransformers) and call them from both paths. - create now captures inserted destination IDs (metaDataId -> id) so destination- scoped filters/transformers map to their connector row, same as update. - Regression test (real Postgres): create a channel with a source filter + source transformer, read it back, assert both persist with their rule/step. Not the cause of the reported save-500 (create + update + script-validation all handle that payload correctly on the dev DB); this is a separate data-loss bug found while investigating it. Server suite: 998 unit passing; integration roundtrip 3 passing; build + lint clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01F1ad6jb5mCYxzFVXHbksqi --- .../__tests__/channel-clone.service.test.ts | 17 +- .../__tests__/channel.service.test.ts | 18 +- .../server/src/services/channel.service.ts | 195 +++++++++++------- .../channel-message-roundtrip.itest.ts | 32 +++ 4 files changed, 172 insertions(+), 90 deletions(-) diff --git a/packages/server/src/services/__tests__/channel-clone.service.test.ts b/packages/server/src/services/__tests__/channel-clone.service.test.ts index 2e55711..b5040c8 100644 --- a/packages/server/src/services/__tests__/channel-clone.service.test.ts +++ b/packages/server/src/services/__tests__/channel-clone.service.test.ts @@ -219,15 +219,18 @@ function setupCreateMocks(channel: Record): void { const tx = { insert: vi.fn().mockImplementation(() => { insertCallCount++; - if (insertCallCount === 1) { - return { - values: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([channel]), + // Channel insert (call 1) returns the created row; later inserts return + // []. Every .values() supports both `.returning()` and direct await. + const rows = insertCallCount === 1 ? [channel] : []; + return { + values: vi.fn().mockReturnValue( + Object.assign(Promise.resolve(rows), { + returning: vi.fn().mockResolvedValue(rows), }), - }; - } - return { values: vi.fn().mockResolvedValue(undefined) }; + ), + }; }), + delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }), }; return fn(tx); }); diff --git a/packages/server/src/services/__tests__/channel.service.test.ts b/packages/server/src/services/__tests__/channel.service.test.ts index 9b7b2b9..6b3affc 100644 --- a/packages/server/src/services/__tests__/channel.service.test.ts +++ b/packages/server/src/services/__tests__/channel.service.test.ts @@ -791,15 +791,19 @@ describe('ChannelService', () => { const tx = { insert: vi.fn().mockImplementation(() => { insertCallCount++; - if (insertCallCount === 1) { - return { - values: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([channel]), + // Channel insert (call 1) returns the created row; later inserts + // (scripts, destinations, filters, transformers) return []. Every + // .values() supports both `.returning()` and direct await. + const rows = insertCallCount === 1 ? [channel] : []; + return { + values: vi.fn().mockReturnValue( + Object.assign(Promise.resolve(rows), { + returning: vi.fn().mockResolvedValue(rows), }), - }; - } - return { values: vi.fn().mockResolvedValue(undefined) }; + ), + }; }), + delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }), }; return fn(tx); }); diff --git a/packages/server/src/services/channel.service.ts b/packages/server/src/services/channel.service.ts index ff03126..95829a8 100644 --- a/packages/server/src/services/channel.service.ts +++ b/packages/server/src/services/channel.service.ts @@ -447,6 +447,104 @@ function buildCloneInput(source: ChannelDetail, newName: string): CreateChannelI }; } +// ----- Filter / transformer persistence (shared by create + update) ----- + +/** Drizzle transaction handle, as passed to `db.transaction(async (tx) => …)`. */ +type ChannelTx = Parameters[0]>[0]; + +/** + * Replace a channel's filters (and their rules) with the provided set. + * Delete-and-reinsert; a source filter has `metaDataId: null`, a destination + * filter is mapped to its connector row via `destIdByMetaDataId`. + */ +async function syncFilters( + tx: ChannelTx, + channelId: string, + filters: NonNullable, + destIdByMetaDataId: ReadonlyMap, +): Promise { + await tx.delete(channelFilters).where(eq(channelFilters.channelId, channelId)); + + for (const filter of filters) { + let resolvedConnectorId: string | null = filter.connectorId ?? null; + if (resolvedConnectorId === null && filter.metaDataId !== undefined && filter.metaDataId !== null) { + resolvedConnectorId = destIdByMetaDataId.get(filter.metaDataId) ?? null; + } + + const [inserted] = await tx + .insert(channelFilters) + .values({ channelId, connectorId: resolvedConnectorId }) + .returning({ id: channelFilters.id }); + + if (inserted && filter.rules.length > 0) { + const ruleValues = filter.rules.map((rule, idx) => ({ + filterId: inserted.id, + sequenceNumber: idx, + enabled: rule.enabled, + name: rule.name ?? null, + operator: rule.operator, + type: rule.type, + script: rule.script ?? null, + field: rule.field ?? null, + condition: rule.condition ?? null, + values: rule.values ?? null, + })); + await tx.insert(filterRules).values(ruleValues); + } + } +} + +/** + * Replace a channel's transformers (and their steps) with the provided set. + * Delete-and-reinsert; a source transformer has `metaDataId: null`, a + * destination transformer is mapped to its connector row via `destIdByMetaDataId`. + */ +async function syncTransformers( + tx: ChannelTx, + channelId: string, + transformers: NonNullable, + destIdByMetaDataId: ReadonlyMap, +): Promise { + await tx.delete(channelTransformers).where(eq(channelTransformers.channelId, channelId)); + + for (const transformer of transformers) { + let resolvedConnectorId: string | null = transformer.connectorId ?? null; + if (resolvedConnectorId === null && transformer.metaDataId !== undefined && transformer.metaDataId !== null) { + resolvedConnectorId = destIdByMetaDataId.get(transformer.metaDataId) ?? null; + } + + const [inserted] = await tx + .insert(channelTransformers) + .values({ + channelId, + connectorId: resolvedConnectorId, + inboundDataType: transformer.inboundDataType, + outboundDataType: transformer.outboundDataType, + inboundProperties: transformer.inboundProperties, + outboundProperties: transformer.outboundProperties, + inboundTemplate: transformer.inboundTemplate ?? null, + outboundTemplate: transformer.outboundTemplate ?? null, + }) + .returning({ id: channelTransformers.id }); + + if (inserted && transformer.steps.length > 0) { + const stepValues = transformer.steps.map((step, idx) => ({ + transformerId: inserted.id, + sequenceNumber: idx, + enabled: step.enabled, + name: step.name ?? null, + type: step.type, + script: step.script ?? null, + sourceField: step.sourceField ?? null, + targetField: step.targetField ?? null, + defaultValue: step.defaultValue ?? null, + mapping: step.mapping ?? null, + })); + await tx.insert(transformerSteps).values(stepValues); + } + } +} + // ----- Service ----- export class ChannelService { @@ -565,7 +663,9 @@ export class ChannelService { await tx.insert(channelScripts).values(scriptValues); - // Insert destinations if provided + // Insert destinations if provided, tracking new IDs by metaDataId so + // source/destination filters + transformers can be mapped to them. + const destIdByMetaDataId = new Map(); if (input.destinations && input.destinations.length > 0) { const destValues = input.destinations.map((dest, index) => ({ channelId, @@ -582,7 +682,13 @@ export class ChannelService { waitForPrevious: dest.waitForPrevious, responseTransformer: dest.responseTransformer ?? null, })); - await tx.insert(channelConnectors).values(destValues); + const insertedDests = await tx.insert(channelConnectors).values(destValues).returning({ + id: channelConnectors.id, + metaDataId: channelConnectors.metaDataId, + }); + for (const d of insertedDests) { + destIdByMetaDataId.set(d.metaDataId, d.id); + } } // Insert metadata columns if provided @@ -596,6 +702,14 @@ export class ChannelService { await tx.insert(channelMetadataColumns).values(metaValues); } + // Persist filters + transformers (previously dropped on create — data loss). + if (input.filters && input.filters.length > 0) { + await syncFilters(tx, channelId, input.filters, destIdByMetaDataId); + } + if (input.transformers && input.transformers.length > 0) { + await syncTransformers(tx, channelId, input.transformers, destIdByMetaDataId); + } + return inserted; }); @@ -769,80 +883,9 @@ export class ChannelService { } } - // Sync filters if provided (delete-and-reinsert, cascade deletes rules) - if (input.filters) { - await tx.delete(channelFilters).where(eq(channelFilters.channelId, id)); - - for (const filter of input.filters) { - let resolvedConnectorId: string | null = filter.connectorId ?? null; - if (resolvedConnectorId === null && filter.metaDataId !== undefined && filter.metaDataId !== null) { - resolvedConnectorId = destIdByMetaDataId.get(filter.metaDataId) ?? null; - } - - const [inserted] = await tx - .insert(channelFilters) - .values({ channelId: id, connectorId: resolvedConnectorId }) - .returning({ id: channelFilters.id }); - - if (inserted && filter.rules.length > 0) { - const ruleValues = filter.rules.map((rule, idx) => ({ - filterId: inserted.id, - sequenceNumber: idx, - enabled: rule.enabled, - name: rule.name ?? null, - operator: rule.operator, - type: rule.type, - script: rule.script ?? null, - field: rule.field ?? null, - condition: rule.condition ?? null, - values: rule.values ?? null, - })); - await tx.insert(filterRules).values(ruleValues); - } - } - } - - // Sync transformers if provided (delete-and-reinsert, cascade deletes steps) - if (input.transformers) { - await tx.delete(channelTransformers).where(eq(channelTransformers.channelId, id)); - - for (const transformer of input.transformers) { - let resolvedConnectorId: string | null = transformer.connectorId ?? null; - if (resolvedConnectorId === null && transformer.metaDataId !== undefined && transformer.metaDataId !== null) { - resolvedConnectorId = destIdByMetaDataId.get(transformer.metaDataId) ?? null; - } - - const [inserted] = await tx - .insert(channelTransformers) - .values({ - channelId: id, - connectorId: resolvedConnectorId, - inboundDataType: transformer.inboundDataType, - outboundDataType: transformer.outboundDataType, - inboundProperties: transformer.inboundProperties, - outboundProperties: transformer.outboundProperties, - inboundTemplate: transformer.inboundTemplate ?? null, - outboundTemplate: transformer.outboundTemplate ?? null, - }) - .returning({ id: channelTransformers.id }); - - if (inserted && transformer.steps.length > 0) { - const stepValues = transformer.steps.map((step, idx) => ({ - transformerId: inserted.id, - sequenceNumber: idx, - enabled: step.enabled, - name: step.name ?? null, - type: step.type, - script: step.script ?? null, - sourceField: step.sourceField ?? null, - targetField: step.targetField ?? null, - defaultValue: step.defaultValue ?? null, - mapping: step.mapping ?? null, - })); - await tx.insert(transformerSteps).values(stepValues); - } - } - } + // Sync filters + transformers if provided (delete-and-reinsert). + if (input.filters) await syncFilters(tx, id, input.filters, destIdByMetaDataId); + if (input.transformers) await syncTransformers(tx, id, input.transformers, destIdByMetaDataId); }); const channel = await findChannel(id); diff --git a/packages/server/test/integration/channel-message-roundtrip.itest.ts b/packages/server/test/integration/channel-message-roundtrip.itest.ts index 84e0b49..895d830 100644 --- a/packages/server/test/integration/channel-message-roundtrip.itest.ts +++ b/packages/server/test/integration/channel-message-roundtrip.itest.ts @@ -9,6 +9,7 @@ import { beforeAll, afterAll, expect, it } from 'vitest'; import { randomUUID } from 'node:crypto'; import type { CreateChannelInput } from '@mirthless/core-models'; +import { createChannelSchema } from '@mirthless/core-models'; import { describeIntegration, loadServerModules, unwrap, type ServerModules } from './_setup.js'; describeIntegration('Channel CRUD + message round trip (real Postgres)', () => { @@ -72,4 +73,35 @@ describeIntegration('Channel CRUD + message round trip (real Postgres)', () => { const dup = await ChannelService.create({ ...input }); expect(dup.ok).toBe(false); }); + + it('persists source filter + transformer on CREATE (regression: they were silently dropped)', async () => { + const { ChannelService } = mods; + const input = createChannelSchema.parse({ + name: `itest-ft-${randomUUID()}`, + description: 'filter+transformer create', + enabled: false, + inboundDataType: 'RAW', + outboundDataType: 'RAW', + sourceConnectorType: 'JAVASCRIPT', + sourceConnectorProperties: {}, + responseMode: 'AUTO_AFTER_DESTINATIONS', + filters: [{ rules: [{ type: 'JAVASCRIPT', script: 'return true;' }] }], + transformers: [{ steps: [{ type: 'JAVASCRIPT', script: "msg = String(msg) + '::x';" }] }], + }); + + const created = unwrap(await ChannelService.create(input)); + const detail = unwrap(await ChannelService.getById(created.id)); + + expect(detail.transformers).toHaveLength(1); + const transformer = detail.transformers[0]; + expect(transformer?.steps).toHaveLength(1); + expect(transformer?.steps[0]?.script).toContain('::x'); + + expect(detail.filters).toHaveLength(1); + const filter = detail.filters[0]; + expect(filter?.rules).toHaveLength(1); + expect(filter?.rules[0]?.type).toBe('JAVASCRIPT'); + + unwrap(await ChannelService.delete(created.id)); + }); }); From bf67e97719b43f20b8e85045af304afd937737e0 Mon Sep 17 00:00:00 2001 From: Michael Hobbs Date: Tue, 14 Jul 2026 22:42:06 -0400 Subject: [PATCH 04/11] feat(engine): ship ambient sandbox types + test TS channel scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sandbox-globals.d.ts: canonical ambient type surface a channel script sees (msg/tmp/maps, logger, parseHL7/createACK, getCollection/getResource/httpFetch/ routeMessage/dbQuery, HL7 proxy). Placed at package root so it never leaks into the engine's own build. Authors type-check scripts against it. - tsconfig.sandbox.json + examples/sandbox-script-example.ts + `typecheck:scripts` script: proves the shipped types resolve for real authored scripts (tsc clean). - ts-channel-scripts.e2e.test.ts: runs real TS transformers (interfaces, generics, typed helpers, as-const unions) through a live channel via the harness — the production esbuild path transpiles them and they transform correctly. - sandbox-types-consistency.test.ts: drift guard asserting the engine .d.ts and the web Monaco string (sandbox-types.ts) declare the same global surface, since web can't import engine and the two are maintained by hand. Engine suite: 379 passing; build + lint + script-typecheck clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01F1ad6jb5mCYxzFVXHbksqi --- .../engine/examples/sandbox-script-example.ts | 27 ++++ packages/engine/package.json | 1 + packages/engine/sandbox-globals.d.ts | 143 ++++++++++++++++++ .../sandbox-types-consistency.test.ts | 38 +++++ .../__tests__/ts-channel-scripts.e2e.test.ts | 80 ++++++++++ packages/engine/tsconfig.sandbox.json | 12 ++ packages/web/src/lib/sandbox-types.ts | 7 +- 7 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 packages/engine/examples/sandbox-script-example.ts create mode 100644 packages/engine/sandbox-globals.d.ts create mode 100644 packages/engine/src/__tests__/sandbox-types-consistency.test.ts create mode 100644 packages/engine/src/__tests__/ts-channel-scripts.e2e.test.ts create mode 100644 packages/engine/tsconfig.sandbox.json diff --git a/packages/engine/examples/sandbox-script-example.ts b/packages/engine/examples/sandbox-script-example.ts new file mode 100644 index 0000000..d6cc605 --- /dev/null +++ b/packages/engine/examples/sandbox-script-example.ts @@ -0,0 +1,27 @@ +// Example channel transformer, authored against the sandbox ambient types +// (../sandbox-globals.d.ts). This file is type-checked by tsconfig.sandbox.json +// (`pnpm --filter @mirthless/engine typecheck:scripts`) but never bundled or run +// — it exists to prove the shipped ambient types resolve for real authoring. +// +// A real transformer body would end in `return `; a top-level return is +// illegal in a standalone .ts file, so this example demonstrates the same global +// surface via statements only. + +const parsed: Hl7MessageProxy = parseHL7(rawData); +logger.info(`received ${parsed.messageType} (${parsed.messageControlId})`); + +const mrn: string = parsed.get('PID.3') ?? 'unknown'; +channelMap['lastMrn'] = mrn; +$c['lastType'] = parsed.messageType; + +// Durable lookup via a Collection, typed end to end. +const priorVisits: Promise = getCollection('visits').find({ mrn }); +void priorVisits.then((records: CollectionRecord[]) => { + logger.debug(`prior visits: ${records.length}`); +}); + +// Config + global maps, and a plain string transform on msg. +const facility: unknown = configMap['facility.name']; +globalMap['seenCount'] = ((globalMap['seenCount'] as number | undefined) ?? 0) + 1; +const normalized: string = String(msg).trim().toUpperCase(); +void [facility, normalized]; diff --git a/packages/engine/package.json b/packages/engine/package.json index 9a8c4a5..23c94e1 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -14,6 +14,7 @@ "scripts": { "dev": "tsc --watch", "build": "tsc", + "typecheck:scripts": "tsc -p tsconfig.sandbox.json", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", diff --git a/packages/engine/sandbox-globals.d.ts b/packages/engine/sandbox-globals.d.ts new file mode 100644 index 0000000..9c4eaae --- /dev/null +++ b/packages/engine/sandbox-globals.d.ts @@ -0,0 +1,143 @@ +// =========================================== +// Sandbox Globals — Ambient Types for Channel Scripts +// =========================================== +// The canonical type surface a filter / transformer / connector script sees when +// it runs inside the engine sandbox (packages/engine/src/sandbox/). Author TS +// channel scripts against these globals to get real type-checking and editor +// completion. +// +// This file lives at the package root (NOT under src/) on purpose: including it +// in the engine's own tsconfig would leak `msg`, `logger`, etc. into the engine +// source as globals. It is consumed only by script-authoring tooling — a +// tsconfig that lists it, or the Monaco editor. The web admin ships an identical +// surface as a string (packages/web/src/lib/sandbox-types.ts) for Monaco; keep +// the two in sync when the sandbox API changes. + +/** HL7 message proxy returned by parseHL7(). Path-based access to HL7 fields. */ +interface Hl7MessageProxy { + /** Get field value by HL7 path (e.g., "MSH.9.1", "PID.3"). */ + get(path: string): string | undefined; + /** Set field value by HL7 path. */ + set(path: string, value: string): void; + /** Delete a field by HL7 path. */ + delete(path: string): void; + /** Serialize back to HL7 pipe-delimited format. */ + toString(): string; + /** Message type (e.g., "ADT^A01"). */ + readonly messageType: string; + /** Message control ID (MSH.10). */ + readonly messageControlId: string; + /** Count of repeating segments by name (e.g., "OBX"). */ + getSegmentCount(name: string): number; + /** Get raw segment string by name and optional repeat index. */ + getSegmentString(name: string, index?: number): string | undefined; +} + +/** Logger for sandbox scripts. */ +interface SandboxLogger { + info(message: string): void; + warn(message: string): void; + error(message: string): void; + debug(message: string): void; +} + +// ----- Global Variables ----- + +/** Current message being processed. Type depends on inbound data type. */ +declare var msg: unknown; +/** Temporary working map. Persists across pipeline stages within a single message. */ +declare var tmp: Record; +/** Original inbound raw data string. */ +declare var rawData: string; +/** Source system metadata. Propagates through the pipeline. */ +declare var sourceMap: Record; +/** Channel-scoped persistent state. Cleared on channel redeploy. */ +declare var channelMap: Record; +/** Connector-scoped state. Lifetime of the connector instance. */ +declare var connectorMap: Record; +/** Response data from destination sends. */ +declare var responseMap: Record; +/** Per-channel map that persists across messages within a deployment. */ +declare var globalChannelMap: Record; +/** Global map (persistent, shared across all channels). */ +declare var globalMap: Record; +/** Configuration map (read-only, frozen). Access via "category.key" format. */ +declare var configMap: Readonly>; +/** Logger for sandbox scripts. */ +declare var logger: SandboxLogger; + +// ----- Map Shortcut Aliases ----- + +/** Alias for channelMap. */ +declare var $c: Record; +/** Alias for responseMap. */ +declare var $r: Record; +/** Alias for globalChannelMap. */ +declare var $g: Record; +/** Alias for globalMap. */ +declare var $gc: Record; + +// ----- Global Functions ----- + +/** Parse an HL7v2 message string into a proxy object. */ +declare function parseHL7(raw: string): Hl7MessageProxy; + +/** Create an HL7 ACK message from the original raw message. */ +declare function createACK(originalRaw: string, ackCode: string, textMessage?: string): string; + +/** A record stored in / returned from a collection. */ +interface CollectionRecord { + readonly id: string; + readonly fields: Readonly>; + readonly payload: string | null; + readonly expireAt: string | null; + readonly createdAt: string; +} + +/** Handle returned by getCollection(name) for reading/writing records. */ +interface CollectionHandle { + store( + fields: Record, + payload: string, + options?: { expireAt?: string; ttlSeconds?: number }, + ): Promise; + find( + match: Record, + options?: { + filter?: Record>; + latest?: boolean; + limit?: number; + order?: 'asc' | 'desc'; + }, + ): Promise; +} + +/** Access a durable, keyed record store shared across channels. */ +declare function getCollection(name: string): CollectionHandle; + +/** Load a configured resource's content by name (or null if none). */ +declare function getResource(name: string): Promise; + +/** The result of an httpFetch call. */ +interface HttpFetchResult { + readonly status: number; + readonly statusText: string; + readonly headers: Readonly>; + readonly body: string; +} + +/** Perform an outbound HTTP request (private/loopback ranges blocked for SSRF). */ +declare function httpFetch( + url: string, + options?: { method?: string; headers?: Record; body?: string; timeout?: number }, +): Promise; + +/** Route a raw message into another deployed, started channel by name. */ +declare function routeMessage(channelName: string, rawData: string): Promise<{ success: boolean; response?: string }>; + +/** Run a parameterized query against a named Data Source. Use params ($1, $2, …). */ +declare function dbQuery( + dataSourceName: string, + sql: string, + params?: readonly unknown[], +): Promise[]>; diff --git a/packages/engine/src/__tests__/sandbox-types-consistency.test.ts b/packages/engine/src/__tests__/sandbox-types-consistency.test.ts new file mode 100644 index 0000000..9a03526 --- /dev/null +++ b/packages/engine/src/__tests__/sandbox-types-consistency.test.ts @@ -0,0 +1,38 @@ +// =========================================== +// Sandbox ambient types ↔ Monaco defs drift guard +// =========================================== +// The engine ships the canonical ambient sandbox types (sandbox-globals.d.ts) +// for authoring TS channel scripts; the web admin carries a mirror string for +// Monaco (packages/web/src/lib/sandbox-types.ts) because web can't import engine. +// This test fails if the two declare a different set of global names, so the +// copies can't silently drift. + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const dtsPath = fileURLToPath(new URL('../../sandbox-globals.d.ts', import.meta.url)); +const webPath = fileURLToPath(new URL('../../../web/src/lib/sandbox-types.ts', import.meta.url)); + +/** Extract the set of `declare var|function` global names from a source string. */ +function declaredGlobals(source: string): Set { + const names = new Set(); + const re = /declare\s+(?:var|function)\s+(\$?\w+)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(source)) !== null) { + if (m[1]) names.add(m[1]); + } + return names; +} + +describe('sandbox ambient types stay in sync with the Monaco defs', () => { + it('engine .d.ts and web SANDBOX_TYPE_DEFS declare the same global names', () => { + const engine = declaredGlobals(readFileSync(dtsPath, 'utf-8')); + const web = declaredGlobals(readFileSync(webPath, 'utf-8')); + + expect(engine.size).toBeGreaterThan(10); + const onlyInEngine = [...engine].filter((n) => !web.has(n)).sort(); + const onlyInWeb = [...web].filter((n) => !engine.has(n)).sort(); + expect({ onlyInEngine, onlyInWeb }).toEqual({ onlyInEngine: [], onlyInWeb: [] }); + }); +}); diff --git a/packages/engine/src/__tests__/ts-channel-scripts.e2e.test.ts b/packages/engine/src/__tests__/ts-channel-scripts.e2e.test.ts new file mode 100644 index 0000000..9264255 --- /dev/null +++ b/packages/engine/src/__tests__/ts-channel-scripts.e2e.test.ts @@ -0,0 +1,80 @@ +// =========================================== +// TypeScript channel scripts — compiled + run end to end +// =========================================== +// Proves that channel scripts authored in real TypeScript (interfaces, generics, +// typed helpers, `as const`) are transpiled by the production esbuild path and +// run correctly through a live channel. The ambient types an author writes these +// against ship in packages/engine/sandbox-globals.d.ts. + +import { describe, it, expect, afterEach } from 'vitest'; +import { TcpMllpReceiver, clearChannelRegistry } from '@mirthless/connectors'; +import { deployChannel, teardownAll, CaptureDestination, type DeployedChannel } from './support/e2e-harness.js'; +import { sendMllp } from './support/tcp-helpers.js'; + +const HL7 = [ + 'MSH|^~\\&|S|F|R|F|20260101||ADT^A01|1|P|2.5', + 'PID|||99887^^^MRN||SMITH^JANE||19700101|F', +].join('\r'); + +let deployed: DeployedChannel[] = []; + +afterEach(async () => { + await teardownAll(deployed); + deployed = []; + clearChannelRegistry(); +}); + +async function runScriptChannel(channelId: string, port: number, transformer: string): Promise { + const sink = new CaptureDestination(); + const channel = await deployChannel({ + channelId, + dataType: 'RAW', + source: new TcpMllpReceiver({ host: '127.0.0.1', port, maxConnections: 10 }), + transformer, + destinations: [{ metaDataId: 1, name: 'sink', connector: sink }], + }); + deployed.push(channel); + await sendMllp(port, HL7); + // Poll for the async dispatch to land. + for (let i = 0; i < 200 && sink.received.length === 0; i++) { + await new Promise((r) => setTimeout(r, 10)); + } + return sink; +} + +describe('TypeScript channel scripts run through a real channel', () => { + it('transformer with an interface + typed helper (HL7 → typed JSON)', async () => { + const transformer = [ + 'interface Patient { readonly mrn: string; readonly name: string; }', + 'function parsePatient(hl7: string): Patient {', + " const pid = hl7.split(String.fromCharCode(13)).find((l: string) => l.startsWith('PID')) ?? '';", + " const fields: readonly string[] = pid.split('|');", + " const mrn: string = (fields[3] ?? '').split('^')[0] ?? '';", + " return { mrn, name: fields[5] ?? '' };", + '}', + 'const p: Patient = parsePatient(String(msg));', + 'return JSON.stringify(p);', + ].join('\n'); + + const sink = await runScriptChannel('00000000-0000-0000-0000-tsscript0001', 17711, transformer); + expect(sink.received).toHaveLength(1); + const out = JSON.parse(sink.lastContent() ?? '{}') as { mrn: string; name: string }; + expect(out.mrn).toBe('99887'); + expect(out.name).toBe('SMITH^JANE'); + }); + + it('transformer with a generic arrow + as-const union', async () => { + const transformer = [ + 'const at = (arr: readonly T[], i: number): T | undefined => arr[i];', + "const KIND = { ADT: 'ADT', ORU: 'ORU' } as const;", + 'type Kind = typeof KIND[keyof typeof KIND];', + "const seg: string = at(String(msg).split(String.fromCharCode(13)), 0) ?? '';", + "const kind: Kind = seg.includes('ADT') ? KIND.ADT : KIND.ORU;", + "return kind + '|' + String(at(seg.split('|'), 9) ?? '');", + ].join('\n'); + + const sink = await runScriptChannel('00000000-0000-0000-0000-tsscript0002', 17712, transformer); + expect(sink.received).toHaveLength(1); + expect(sink.lastContent()).toContain('ADT|'); + }); +}); diff --git a/packages/engine/tsconfig.sandbox.json b/packages/engine/tsconfig.sandbox.json new file mode 100644 index 0000000..5a67871 --- /dev/null +++ b/packages/engine/tsconfig.sandbox.json @@ -0,0 +1,12 @@ +{ + "//": "Type-checks example channel scripts against the shipped sandbox ambient types (sandbox-globals.d.ts). Not part of the build. Run: pnpm --filter @mirthless/engine typecheck:scripts", + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": false, + "composite": false + }, + "include": ["sandbox-globals.d.ts", "examples/**/*.ts"] +} diff --git a/packages/web/src/lib/sandbox-types.ts b/packages/web/src/lib/sandbox-types.ts index 0bf092c..7831150 100644 --- a/packages/web/src/lib/sandbox-types.ts +++ b/packages/web/src/lib/sandbox-types.ts @@ -2,7 +2,12 @@ // Sandbox TypeScript Definitions // =========================================== // Type definitions for Monaco IntelliSense in sandbox script editors. -// Matches the sandbox executor globals from packages/engine/src/sandbox/. +// +// CANONICAL SOURCE: packages/engine/sandbox-globals.d.ts. This string mirrors +// that .d.ts for the Monaco editor (web cannot import the engine package). The +// two are kept in sync by a drift-guard test (sandbox-types-consistency.test.ts +// in the engine package) that asserts they declare the same global surface — +// update BOTH when the sandbox API changes. export const SANDBOX_TYPE_DEFS = ` /** From d7b071d07a404e5ff41520d6ab4fe4f2ad0cf7d2 Mon Sep 17 00:00:00 2001 From: Michael Hobbs Date: Tue, 14 Jul 2026 22:44:12 -0400 Subject: [PATCH 05/11] test(engine): JavaScript source + destination connector E2E Wires a real sandbox-backed ScriptRunner (as the production engine does) and drives messages through the JS connectors: - JS source: a polling script generates a message that flows to the sink. - JS destination: a script uppercases the message; its return value is the dispatch response, and the runner is observed receiving the exact content. Connectors covered by real-message E2E: TCP/MLLP, Channel, File, HTTP, JavaScript. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01F1ad6jb5mCYxzFVXHbksqi --- .../src/__tests__/connector-js.e2e.test.ts | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 packages/engine/src/__tests__/connector-js.e2e.test.ts diff --git a/packages/engine/src/__tests__/connector-js.e2e.test.ts b/packages/engine/src/__tests__/connector-js.e2e.test.ts new file mode 100644 index 0000000..7f953a6 --- /dev/null +++ b/packages/engine/src/__tests__/connector-js.e2e.test.ts @@ -0,0 +1,105 @@ +// =========================================== +// JavaScript connector E2E — source + destination +// =========================================== +// The JS connectors run user scripts via a ScriptRunner the engine injects. +// These tests wire a real sandbox-backed runner (exactly as the production +// engine does) and drive messages through: +// - JS source: a polling script generates a message → delivered to a sink. +// - JS destination: a script transforms the message → its return value is the +// dispatch response. + +import { describe, it, expect, afterEach } from 'vitest'; +import { + JavaScriptReceiver, + JavaScriptDispatcher, + TcpMllpReceiver, + clearChannelRegistry, + type ConnectorMessage, +} from '@mirthless/connectors'; +import type { Result } from '@mirthless/core-util'; +import { VmSandboxExecutor, compileScript, DEFAULT_EXECUTION_OPTIONS } from '../index.js'; +import { createSandboxContext } from '../sandbox/sandbox-context.js'; +import { deployChannel, teardownAll, CaptureDestination, type DeployedChannel } from './support/e2e-harness.js'; +import { sendMllp } from './support/tcp-helpers.js'; + +let deployed: DeployedChannel[] = []; +const sandbox = new VmSandboxExecutor(); + +afterEach(async () => { + await teardownAll(deployed); + deployed = []; + clearChannelRegistry(); +}); + +async function compile(source: string): Promise<{ code: string }> { + const r = await compileScript(source, { sourcefile: 'js-connector.js' }); + if (!r.ok) throw new Error(`compile failed: ${r.error.message}`); + return r.value; +} + +describe('JavaScript source connector (a polling script generates messages)', () => { + it('runs the source script and delivers its generated message to the destination', async () => { + const compiled = await compile("return 'HEARTBEAT|' + msg;"); + const source = new JavaScriptReceiver({ script: 'ignored', pollingIntervalMs: 100 }); + + // Inject the sandbox runner (as the engine does). Generate exactly one + // message so the polling source is deterministic. + let calls = 0; + source.setScriptRunner(async (): Promise> => { + calls += 1; + if (calls > 1) return { ok: true, value: null, error: null } as Result; + const exec = await sandbox.execute(compiled, createSandboxContext('OK', 'OK'), DEFAULT_EXECUTION_OPTIONS); + if (!exec.ok) return exec; + return { ok: true, value: exec.value.returnValue, error: null } as Result; + }); + + const sink = new CaptureDestination(); + const channel = await deployChannel({ + channelId: '00000000-0000-0000-0000-connjs00src1', + dataType: 'RAW', + source, + destinations: [{ metaDataId: 1, name: 'sink', connector: sink }], + }); + deployed.push(channel); + + for (let i = 0; i < 200 && sink.received.length === 0; i++) { + await new Promise((r) => setTimeout(r, 10)); + } + expect(sink.received.length).toBeGreaterThanOrEqual(1); + expect(sink.lastContent()).toBe('HEARTBEAT|OK'); + }); +}); + +describe('JavaScript destination connector (a script transforms the message)', () => { + it('runs the destination script against the message and returns its result', async () => { + const compiled = await compile('return String(msg).toUpperCase();'); + const dispatcher = new JavaScriptDispatcher({ script: 'ignored' }); + + const seen: { input: string; output: unknown }[] = []; + dispatcher.setScriptRunner(async (_script: string, content: string, _cm: ConnectorMessage): Promise> => { + const exec = await sandbox.execute(compiled, createSandboxContext(content, content), DEFAULT_EXECUTION_OPTIONS); + const output = exec.ok ? exec.value.returnValue : null; + seen.push({ input: content, output }); + if (!exec.ok) return exec; + return { ok: true, value: output, error: null } as Result; + }); + + const channel = await deployChannel({ + channelId: '00000000-0000-0000-0000-connjs0dst01', + dataType: 'RAW', + source: new TcpMllpReceiver({ host: '127.0.0.1', port: 17721, maxConnections: 10 }), + destinations: [{ metaDataId: 1, name: 'JS Dest', connector: dispatcher }], + }); + deployed.push(channel); + + await sendMllp(17721, 'MSH|^~\\&|abc'); + for (let i = 0; i < 200 && seen.length === 0; i++) { + await new Promise((r) => setTimeout(r, 10)); + } + + expect(seen).toHaveLength(1); + expect(seen[0]?.input).toBe('MSH|^~\\&|abc'); + expect(seen[0]?.output).toBe('MSH|^~\\&|ABC'); + expect(channel.store.messageCount()).toBe(1); + }); +}); From 4e4dd2a9680e2ad3ac7ac0bf6756dffc343f54d4 Mon Sep 17 00:00:00 2001 From: Michael Hobbs Date: Tue, 14 Jul 2026 22:47:56 -0400 Subject: [PATCH 06/11] test(engine): integration lane + real Database destination connector Adds an engine integration lane (vitest.integration.config.ts, *.itest.ts) for connector suites that need real infrastructure. Each suite self-skips when its service env is absent, so the lane is safe to run anywhere. - gates.ts: env-driven gates for Postgres (*_test DB), SFTP, and SMTP/IMAP. - connector-db.itest.ts: TCP source -> transform -> Database dispatcher INSERT into a real table; reads the row back to prove delivery. Runs against a *_test Postgres, skips otherwise. - `pnpm --filter @mirthless/engine test:integration`. Verified green against mirthless_test; skips cleanly without a test DB. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01F1ad6jb5mCYxzFVXHbksqi --- packages/engine/package.json | 1 + .../integration/connector-db.itest.ts | 71 ++++++++++++ .../engine/src/__tests__/integration/gates.ts | 106 ++++++++++++++++++ packages/engine/vitest.integration.config.ts | 22 ++++ 4 files changed, 200 insertions(+) create mode 100644 packages/engine/src/__tests__/integration/connector-db.itest.ts create mode 100644 packages/engine/src/__tests__/integration/gates.ts create mode 100644 packages/engine/vitest.integration.config.ts diff --git a/packages/engine/package.json b/packages/engine/package.json index 23c94e1..ef0ee0c 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -16,6 +16,7 @@ "build": "tsc", "typecheck:scripts": "tsc -p tsconfig.sandbox.json", "test": "vitest run", + "test:integration": "vitest run --config vitest.integration.config.ts", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "clean": "rimraf dist" diff --git a/packages/engine/src/__tests__/integration/connector-db.itest.ts b/packages/engine/src/__tests__/integration/connector-db.itest.ts new file mode 100644 index 0000000..9806184 --- /dev/null +++ b/packages/engine/src/__tests__/integration/connector-db.itest.ts @@ -0,0 +1,71 @@ +// =========================================== +// Database destination connector — real Postgres +// =========================================== +// Sends a message through a channel whose destination is the Database dispatcher, +// then reads the row back from a real table to prove it landed. +// Runs only when DATABASE_URL points at a *_test database. + +import { it, expect, beforeAll, afterAll } from 'vitest'; +import { ConnectionPool, TcpMllpReceiver, DatabaseDispatcher, clearChannelRegistry } from '@mirthless/connectors'; +import { deployChannel, teardownAll } from '../support/e2e-harness.js'; +import { sendMllp } from '../support/tcp-helpers.js'; +import { describeDb, requireDb } from './gates.js'; + +const TABLE = 'e2e_db_dest'; +const PORT = 17731; + +describeDb('Database destination connector (real Postgres)', () => { + const pool = new ConnectionPool(); + + beforeAll(async () => { + const cfg = requireDb(); + const created = await pool.create({ ...cfg, maxConnections: 2 }); + if (!created.ok) throw created.error; + await pool.query(`DROP TABLE IF EXISTS ${TABLE}`, []); + await pool.query(`CREATE TABLE ${TABLE} (id serial PRIMARY KEY, message_id integer, payload text)`, []); + }); + + afterAll(async () => { + await pool.query(`DROP TABLE IF EXISTS ${TABLE}`, []); + await pool.destroy(); + clearChannelRegistry(); + }); + + it('inserts the transformed message into a real table via the Database dispatcher', async () => { + const cfg = requireDb(); + const dispatcher = new DatabaseDispatcher({ + host: cfg.host, + port: cfg.port, + database: cfg.database, + username: cfg.user, + password: cfg.password, + query: `INSERT INTO ${TABLE} (message_id, payload) VALUES ($\{messageId}, $\{content})`, + useTransaction: false, + returnGeneratedKeys: false, + }); + + const channel = await deployChannel({ + channelId: '00000000-0000-0000-0000-conndb000001', + dataType: 'RAW', + source: new TcpMllpReceiver({ host: '127.0.0.1', port: PORT, maxConnections: 10 }), + transformer: "return String(msg) + '::db';", + destinations: [{ metaDataId: 1, name: 'DB Out', connector: dispatcher }], + }); + + try { + await sendMllp(PORT, 'MSH|^~\\&|DBTEST'); + + let rows: readonly Record[] = []; + for (let i = 0; i < 200; i++) { + const r = await pool.query(`SELECT message_id, payload FROM ${TABLE}`, []); + if (r.ok && r.value.rows.length > 0) { rows = r.value.rows; break; } + await new Promise((res) => setTimeout(res, 10)); + } + + expect(rows).toHaveLength(1); + expect(String(rows[0]?.payload)).toBe('MSH|^~\\&|DBTEST::db'); + } finally { + await teardownAll([channel]); + } + }); +}); diff --git a/packages/engine/src/__tests__/integration/gates.ts b/packages/engine/src/__tests__/integration/gates.ts new file mode 100644 index 0000000..2dbaf9a --- /dev/null +++ b/packages/engine/src/__tests__/integration/gates.ts @@ -0,0 +1,106 @@ +// =========================================== +// Integration-test gates +// =========================================== +// Each connector-integration suite runs only when its backing service is +// configured (via env), and self-skips otherwise. This keeps the integration +// lane runnable anywhere while still exercising real protocols in CI / docker. + +import { describe } from 'vitest'; +import type { PoolConfig } from '@mirthless/connectors'; + +// ----- Postgres (DATABASE_URL ending in *_test) ----- + +export interface TestDbConfig extends PoolConfig { + readonly url: string; +} + +function readTestDbConfig(): TestDbConfig | null { + const url = process.env.DATABASE_URL; + if (!url) return null; + try { + const u = new URL(url); + const database = u.pathname.replace(/^\//, ''); + // Guard developer/prod DBs: only a *_test database is safe for CREATE/DROP. + if (!/_test$/i.test(database)) return null; + return { + url, + host: u.hostname, + port: u.port ? Number(u.port) : 5432, + database, + user: decodeURIComponent(u.username), + password: decodeURIComponent(u.password), + maxConnections: 4, + idleTimeoutMs: 10_000, + connectionTimeoutMs: 10_000, + }; + } catch { + return null; + } +} + +export const dbConfig = readTestDbConfig(); +export const describeDb = dbConfig ? describe : describe.skip; + +/** Non-null DB config for use inside hooks/tests (never called when skipped). */ +export function requireDb(): TestDbConfig { + if (!dbConfig) throw new Error('DATABASE_URL is not a *_test database'); + return dbConfig; +} + +// ----- SFTP (SFTP_TEST_HOST) ----- + +export interface TestSftpConfig { + readonly host: string; + readonly port: number; + readonly username: string; + readonly password: string; + readonly baseDir: string; +} + +function readSftpConfig(): TestSftpConfig | null { + const host = process.env.SFTP_TEST_HOST; + if (!host) return null; + return { + host, + port: process.env.SFTP_TEST_PORT ? Number(process.env.SFTP_TEST_PORT) : 2222, + username: process.env.SFTP_TEST_USER ?? 'mirth', + password: process.env.SFTP_TEST_PASSWORD ?? 'mirthpw', + baseDir: process.env.SFTP_TEST_DIR ?? '/upload', + }; +} + +export const sftpConfig = readSftpConfig(); +export const describeSftp = sftpConfig ? describe : describe.skip; +export function requireSftp(): TestSftpConfig { + if (!sftpConfig) throw new Error('SFTP_TEST_HOST is not set'); + return sftpConfig; +} + +// ----- SMTP / IMAP (GreenMail: SMTP_TEST_HOST) ----- + +export interface TestMailConfig { + readonly host: string; + readonly smtpPort: number; + readonly imapPort: number; + readonly username: string; + readonly password: string; +} + +function readMailConfig(): TestMailConfig | null { + const host = process.env.SMTP_TEST_HOST; + if (!host) return null; + return { + host, + smtpPort: process.env.SMTP_TEST_PORT ? Number(process.env.SMTP_TEST_PORT) : 3025, + imapPort: process.env.IMAP_TEST_PORT ? Number(process.env.IMAP_TEST_PORT) : 3143, + username: process.env.MAIL_TEST_USER ?? 'mirth@example.com', + password: process.env.MAIL_TEST_PASSWORD ?? 'mirthpw', + }; +} + +export const mailConfig = readMailConfig(); +export const describeMail = mailConfig ? describe : describe.skip; +export function requireMail(): TestMailConfig { + if (!mailConfig) throw new Error('SMTP_TEST_HOST is not set'); + return mailConfig; +} diff --git a/packages/engine/vitest.integration.config.ts b/packages/engine/vitest.integration.config.ts new file mode 100644 index 0000000..65898e2 --- /dev/null +++ b/packages/engine/vitest.integration.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vitest/config'; + +// =========================================== +// Integration (real services) Vitest Config +// =========================================== +// Runs ONLY the *.itest.ts suites, which drive real messages through connectors +// backed by real infrastructure (Postgres, SFTP, SMTP/IMAP) provided by the +// docker-compose test services. Each suite self-skips when its service env is +// absent, so this config is safe to run anywhere. Kept separate from the default +// vitest.config.ts (which excludes *.itest.ts) so `pnpm test` never needs infra. + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.itest.ts'], + passWithNoTests: true, + pool: 'forks', + testTimeout: 20_000, + hookTimeout: 30_000, + }, +}); From 7ac7f23fb6d3c396bc581ce7860286fe3a368296 Mon Sep 17 00:00:00 2001 From: Michael Hobbs Date: Tue, 14 Jul 2026 22:51:33 -0400 Subject: [PATCH 07/11] test(engine): real SFTP cascade + docker test services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker-compose: add a `test` profile with sftp-test (atmoz/sftp) and mail-test (GreenMail SMTP+IMAP), matching the integration gates' env. Start with `docker compose --profile test up`. - connector-sftp.itest.ts: the vision's SFTP listen ↔ SFTP dest cascade — Channel A (TCP source → SFTP destination) uploads a file; Channel B (SFTP source polling the same dir) picks it up, transforms, and delivers to a sink. Verified against the real atmoz/sftp server; skips without SFTP_TEST_HOST. Connectors covered by real-message E2E: TCP/MLLP, Channel, File, HTTP, JavaScript, Database, SFTP. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01F1ad6jb5mCYxzFVXHbksqi --- docker/docker-compose.yml | 28 ++++++ .../integration/connector-sftp.itest.ts | 89 +++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 packages/engine/src/__tests__/integration/connector-sftp.itest.ts diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index eba6033..e80b0c5 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -17,5 +17,33 @@ services: timeout: 5s retries: 5 + # --- Integration-test services (only start with: docker compose --profile test up) --- + # Back the engine's connector integration lane (packages/engine *.itest.ts). + # Match the env the gates expect (packages/engine/src/__tests__/integration/gates.ts). + + sftp-test: + image: atmoz/sftp:alpine + container_name: mirthless-sftp-test + profiles: ["test"] + # user:password:::dir → creates /home/mirth/upload, writable, chrooted. + command: mirth:mirthpw:::upload + ports: + - "2222:22" + + mail-test: + # GreenMail: SMTP (3025) + IMAP (3143) in one container for mail connectors. + image: greenmail/standalone:2.1.0 + container_name: mirthless-mail-test + profiles: ["test"] + environment: + GREENMAIL_OPTS: >- + -Dgreenmail.setup.test.all + -Dgreenmail.hostname=0.0.0.0 + -Dgreenmail.users=mirth:mirthpw@example.com + -Dgreenmail.auth.disabled + ports: + - "3025:3025" + - "3143:3143" + volumes: postgres_data: diff --git a/packages/engine/src/__tests__/integration/connector-sftp.itest.ts b/packages/engine/src/__tests__/integration/connector-sftp.itest.ts new file mode 100644 index 0000000..d218616 --- /dev/null +++ b/packages/engine/src/__tests__/integration/connector-sftp.itest.ts @@ -0,0 +1,89 @@ +// =========================================== +// SFTP connector cascade — real SFTP server +// =========================================== +// The vision's "Channel SFTP listen + another channel with an SFTP destination": +// Channel A: TCP source → SFTP destination (uploads a file to the server). +// Channel B: SFTP source (polls the same dir) → transform → sink. +// A message pushed into A is uploaded over SFTP, picked up by B, and delivered. +// Runs only when SFTP_TEST_HOST is set (docker compose --profile test up sftp-test). + +import { it, expect, afterEach } from 'vitest'; +import { + TcpMllpReceiver, + SftpReceiver, + SftpDispatcher, + SFTP_POST_ACTION, + clearChannelRegistry, +} from '@mirthless/connectors'; +import { deployChannel, teardownAll, CaptureDestination, type DeployedChannel } from '../support/e2e-harness.js'; +import { sendMllp } from '../support/tcp-helpers.js'; +import { describeSftp, requireSftp } from './gates.js'; + +const TCP_PORT = 17741; + +let deployed: DeployedChannel[] = []; +afterEach(async () => { + await teardownAll(deployed); + deployed = []; + clearChannelRegistry(); +}); + +describeSftp('SFTP connector cascade (real SFTP server)', () => { + it('Channel A uploads via SFTP destination; Channel B\'s SFTP source picks it up', async () => { + const cfg = requireSftp(); + const conn = { + host: cfg.host, + port: cfg.port, + username: cfg.username, + password: cfg.password, + strictHostKey: false, + }; + + const sink = new CaptureDestination(); + + // Channel B: SFTP source polls the upload dir, deletes after processing. + const channelB = await deployChannel({ + channelId: '00000000-0000-0000-0000-connsftp000b', + dataType: 'RAW', + source: new SftpReceiver({ + ...conn, + remoteDirectory: cfg.baseDir, + filePattern: '*.hl7', + pollingIntervalMs: 200, + afterProcessing: SFTP_POST_ACTION.DELETE, + moveToDirectory: '', + minFileAgeMs: 0, + }), + transformer: "return String(msg) + '::sftp';", + destinations: [{ metaDataId: 1, name: 'sink', connector: sink }], + }); + + // Channel A: TCP source → SFTP destination uploads ${messageId}.hl7. + const channelA = await deployChannel({ + channelId: '00000000-0000-0000-0000-connsftp000a', + dataType: 'RAW', + source: new TcpMllpReceiver({ host: '127.0.0.1', port: TCP_PORT, maxConnections: 10 }), + destinations: [{ + metaDataId: 1, + name: 'SFTP Out', + connector: new SftpDispatcher({ + ...conn, + remoteDirectory: cfg.baseDir, + fileNameTemplate: 'msg-${messageId}.hl7', + appendMode: false, + }), + }], + }); + deployed.push(channelA, channelB); + + await sendMllp(TCP_PORT, 'MSH|^~\\&|SFTPTEST'); + + // Wait for the upload → poll → download → deliver round trip. + for (let i = 0; i < 300 && sink.received.length === 0; i++) { + await new Promise((r) => setTimeout(r, 20)); + } + + expect(sink.received).toHaveLength(1); + expect(sink.lastContent()).toBe('MSH|^~\\&|SFTPTEST::sftp'); + }); +}); From a5785823567e17495e8e72506fd24a5a8b71fca2 Mon Sep 17 00:00:00 2001 From: Michael Hobbs Date: Tue, 14 Jul 2026 22:56:53 -0400 Subject: [PATCH 08/11] test(engine): real SMTP + IMAP mail cascade (GreenMail) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - connector-mail.itest.ts: Channel A (TCP source → SMTP destination) emails the message; Channel B (Email/IMAP source polling the inbox) picks it up, transforms, and delivers to a sink. Verified against GreenMail; skips without SMTP_TEST_HOST. - gates.ts: split the mail login (GreenMail user "mirth") from the envelope address ("mirth@example.com") — IMAP authenticates with the login, SMTP addresses the email. - docker-compose: drop greenmail.auth.disabled (it broke IMAP LOGIN); the predefined user handles auth. Connectors covered by real-message E2E: TCP/MLLP, Channel, File, HTTP, JavaScript, Database, SFTP, SMTP, IMAP. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01F1ad6jb5mCYxzFVXHbksqi --- docker/docker-compose.yml | 1 - .../integration/connector-mail.itest.ts | 95 +++++++++++++++++++ .../engine/src/__tests__/integration/gates.ts | 6 +- 3 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 packages/engine/src/__tests__/integration/connector-mail.itest.ts diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index e80b0c5..eba42bb 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -40,7 +40,6 @@ services: -Dgreenmail.setup.test.all -Dgreenmail.hostname=0.0.0.0 -Dgreenmail.users=mirth:mirthpw@example.com - -Dgreenmail.auth.disabled ports: - "3025:3025" - "3143:3143" diff --git a/packages/engine/src/__tests__/integration/connector-mail.itest.ts b/packages/engine/src/__tests__/integration/connector-mail.itest.ts new file mode 100644 index 0000000..1296cbf --- /dev/null +++ b/packages/engine/src/__tests__/integration/connector-mail.itest.ts @@ -0,0 +1,95 @@ +// =========================================== +// SMTP + IMAP connector cascade — real GreenMail server +// =========================================== +// Channel A: TCP source → SMTP destination (sends an email). +// Channel B: Email (IMAP) source polls the inbox → transform → sink. +// A message pushed into A is emailed, picked up over IMAP by B, and delivered. +// Runs only when SMTP_TEST_HOST is set (docker compose --profile test up mail-test). + +import { it, expect, afterEach } from 'vitest'; +import { + TcpMllpReceiver, + SmtpDispatcher, + EmailReceiver, + clearChannelRegistry, +} from '@mirthless/connectors'; +import { deployChannel, teardownAll, CaptureDestination, type DeployedChannel } from '../support/e2e-harness.js'; +import { sendMllp } from '../support/tcp-helpers.js'; +import { describeMail, requireMail } from './gates.js'; + +const TCP_PORT = 17751; + +let deployed: DeployedChannel[] = []; +afterEach(async () => { + await teardownAll(deployed); + deployed = []; + clearChannelRegistry(); +}); + +describeMail('SMTP + IMAP connector cascade (real GreenMail)', () => { + it('Channel A emails the message via SMTP; Channel B\'s IMAP source picks it up', async () => { + const cfg = requireMail(); + const address = cfg.address; // envelope email, e.g. mirth@example.com + + const sink = new CaptureDestination(); + + // Channel B: IMAP source polls INBOX for our subject. + const channelB = await deployChannel({ + channelId: '00000000-0000-0000-0000-connmail000b', + dataType: 'RAW', + source: new EmailReceiver({ + host: cfg.host, + port: cfg.imapPort, + secure: false, + username: cfg.username, + password: cfg.password, + protocol: 'IMAP', + folder: 'INBOX', + pollingIntervalMs: 1000, + postAction: 'MARK_READ', + moveToFolder: '', + subjectFilter: 'E2E-MAIL', + includeAttachments: false, + }), + transformer: "return String(msg).trim() + '::mail';", + destinations: [{ metaDataId: 1, name: 'sink', connector: sink }], + }); + + // Channel A: TCP source → SMTP destination emails ${msg}. + const channelA = await deployChannel({ + channelId: '00000000-0000-0000-0000-connmail000a', + dataType: 'RAW', + source: new TcpMllpReceiver({ host: '127.0.0.1', port: TCP_PORT, maxConnections: 10 }), + destinations: [{ + metaDataId: 1, + name: 'SMTP Out', + connector: new SmtpDispatcher({ + host: cfg.host, + port: cfg.smtpPort, + secure: false, + requireTLS: false, + from: address, + to: address, + cc: '', + bcc: '', + subject: 'E2E-MAIL', + bodyTemplate: '${msg}', + contentType: 'text/plain', + attachContent: false, + }), + }], + }); + deployed.push(channelA, channelB); + + await sendMllp(TCP_PORT, 'MAILTEST-PAYLOAD'); + + // Wait for SMTP delivery + the 1s IMAP poll to pick it up. + for (let i = 0; i < 300 && sink.received.length === 0; i++) { + await new Promise((r) => setTimeout(r, 50)); + } + + expect(sink.received.length).toBeGreaterThanOrEqual(1); + expect(sink.lastContent()).toContain('MAILTEST-PAYLOAD'); + expect(sink.lastContent()).toContain('::mail'); + }); +}); diff --git a/packages/engine/src/__tests__/integration/gates.ts b/packages/engine/src/__tests__/integration/gates.ts index 2dbaf9a..4475613 100644 --- a/packages/engine/src/__tests__/integration/gates.ts +++ b/packages/engine/src/__tests__/integration/gates.ts @@ -82,8 +82,11 @@ export interface TestMailConfig { readonly host: string; readonly smtpPort: number; readonly imapPort: number; + /** IMAP/SMTP login (GreenMail user login — e.g. "mirth"). */ readonly username: string; readonly password: string; + /** Mailbox email address (e.g. "mirth@example.com") — the SMTP envelope. */ + readonly address: string; } function readMailConfig(): TestMailConfig | null { @@ -93,8 +96,9 @@ function readMailConfig(): TestMailConfig | null { host, smtpPort: process.env.SMTP_TEST_PORT ? Number(process.env.SMTP_TEST_PORT) : 3025, imapPort: process.env.IMAP_TEST_PORT ? Number(process.env.IMAP_TEST_PORT) : 3143, - username: process.env.MAIL_TEST_USER ?? 'mirth@example.com', + username: process.env.MAIL_TEST_USER ?? 'mirth', password: process.env.MAIL_TEST_PASSWORD ?? 'mirthpw', + address: process.env.MAIL_TEST_ADDRESS ?? 'mirth@example.com', }; } From 484d430ee88cbfa5ea37f15876a37ab5ad38eb98 Mon Sep 17 00:00:00 2001 From: Michael Hobbs Date: Tue, 14 Jul 2026 22:59:17 -0400 Subject: [PATCH 09/11] test(engine): real FHIR destination connector E2E connector-matrix: TCP source -> transform HL7 into a FHIR Patient resource -> FhirDispatcher POSTs it to a local FHIR endpoint. Asserts the POST path (/Patient), the application/fhir+json content type, and the resource body. No external infra (local http mock), so it runs in the default unit lane. Connectors covered by real-message E2E: TCP/MLLP, Channel, File, HTTP, JavaScript, Database, SFTP, SMTP, IMAP, FHIR. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01F1ad6jb5mCYxzFVXHbksqi --- .../__tests__/connector-matrix.e2e.test.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/packages/engine/src/__tests__/connector-matrix.e2e.test.ts b/packages/engine/src/__tests__/connector-matrix.e2e.test.ts index b0d5255..7f64e96 100644 --- a/packages/engine/src/__tests__/connector-matrix.e2e.test.ts +++ b/packages/engine/src/__tests__/connector-matrix.e2e.test.ts @@ -21,8 +21,11 @@ import { FILE_POST_ACTION, HttpReceiver, HttpDispatcher, + FhirDispatcher, + TcpMllpReceiver, } from '@mirthless/connectors'; import { deployChannel, teardownAll, type DeployedChannel } from './support/e2e-harness.js'; +import { sendMllp } from './support/tcp-helpers.js'; let deployed: DeployedChannel[] = []; const tempDirs: string[] = []; @@ -141,10 +144,73 @@ describe('HTTP connector (POST in → transform → POST out to a downstream ser }, 15_000); }); +// ----- FHIR connector (destination) ----- + +describe('FHIR connector (POST a resource in → transform to FHIR → POST to a FHIR server)', () => { + it('transforms the message into a FHIR Patient and POSTs it to a FHIR endpoint', async () => { + const received: { path: string; contentType: string; body: string }[] = []; + const fhirServer = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += String(chunk); }); + req.on('end', () => { + received.push({ path: req.url ?? '', contentType: req.headers['content-type'] ?? '', body }); + res.writeHead(201, { 'Content-Type': 'application/fhir+json', Location: '/Patient/123' }); + res.end(body); + }); + }); + await listen(fhirServer, FHIR_PORT); + servers.push(fhirServer); + + const channel = await deployChannel({ + channelId: '00000000-0000-0000-0000-connfhir0001', + dataType: 'RAW', + source: new TcpMllpReceiver({ host: '127.0.0.1', port: FHIR_SRC_PORT, maxConnections: 10 }), + // Build a FHIR Patient from the HL7 PID-5 name. + transformer: [ + "const pid = String(msg).split(String.fromCharCode(13)).find((l) => l.indexOf('PID') === 0) || '';", + "const name = (pid.split('|')[5] || '').split('^');", + 'const patient = { resourceType: "Patient", name: [{ family: name[0] || "", given: [name[1] || ""] }] };', + 'return JSON.stringify(patient);', + ].join('\n'), + destinations: [{ + metaDataId: 1, + name: 'FHIR Out', + connector: new FhirDispatcher({ + baseUrl: `http://127.0.0.1:${String(FHIR_PORT)}`, + resourceType: 'Patient', + method: 'POST', + authType: 'NONE', + authConfig: {}, + format: 'json', + timeout: 5_000, + headers: {}, + }), + }], + }); + deployed.push(channel); + + await sendMllp(FHIR_SRC_PORT, [ + 'MSH|^~\\&|S|F|R|F|20260101||ADT^A01|1|P|2.5', + 'PID|||1^^^MRN||DOE^JOHN', + ].join('\r')); + + await waitForSync(() => received.length === 1); + const posted = received[0]; + expect(posted?.path).toBe('/Patient'); + expect(posted?.contentType).toContain('application/fhir+json'); + const resource = JSON.parse(posted?.body ?? '{}') as { resourceType: string; name: { family: string; given: string[] }[] }; + expect(resource.resourceType).toBe('Patient'); + expect(resource.name[0]?.family).toBe('DOE'); + expect(resource.name[0]?.given[0]).toBe('JOHN'); + }); +}); + // ----- ports + helpers ----- const HTTP_SRC_PORT = 17701; const DOWNSTREAM_PORT = 17702; +const FHIR_PORT = 17703; +const FHIR_SRC_PORT = 17704; async function listen(server: http.Server, port: number): Promise { await new Promise((resolve, reject) => { From 7248bd00363771e725884d8bce7fe19ebda4e4a2 Mon Sep 17 00:00:00 2001 From: Michael Hobbs Date: Tue, 14 Jul 2026 23:09:43 -0400 Subject: [PATCH 10/11] =?UTF-8?q?test(engine):=20DICOM=20SCU=E2=86=92SCP?= =?UTF-8?q?=20cascade=20reproducer=20(opt-in)=20+=20finding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the DICOM listen↔dest cascade with the real @ubercode/dcmtk binaries: Channel A (TCP source → DICOM destination / storescu C-STORE) → Channel B (DICOM source / storescp SCP) → sink. Vendors a small real DICOM object as a fixture (sample.dcm). FINDING (why it's opt-in, gated on DICOM_TEST_ENABLED and skipped by default): the SCP is reachable but rejects the association at DICOM negotiation — "Rejected Permanent, Source: Service User". The receiver wrapper (dicom-receiver.ts defaultReceiverFactory) creates the dcmtk DicomReceiver with no accepted SOP-class / presentation-context configuration, so it won't negotiate a context for the object's SOP class. This is a real DICOM-connector gap; once the SCP accepts the context, the test passes unchanged. Kept as a ready reproducer rather than grinding on dcmtk negotiation here. Real-message E2E coverage: 10/11 connector types PASSING (TCP/MLLP, Channel, File, HTTP, JavaScript, Database, SFTP, SMTP, IMAP, FHIR); DICOM wired with a documented reproducer pending the SCP negotiation fix. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01F1ad6jb5mCYxzFVXHbksqi --- .../engine/src/__tests__/fixtures/sample.dcm | Bin 0 -> 132862 bytes .../integration/connector-dicom.itest.ts | 121 ++++++++++++++++++ .../engine/src/__tests__/integration/gates.ts | 8 ++ 3 files changed, 129 insertions(+) create mode 100644 packages/engine/src/__tests__/fixtures/sample.dcm create mode 100644 packages/engine/src/__tests__/integration/connector-dicom.itest.ts diff --git a/packages/engine/src/__tests__/fixtures/sample.dcm b/packages/engine/src/__tests__/fixtures/sample.dcm new file mode 100644 index 0000000000000000000000000000000000000000..479bfb3108a487c3e843218fda37afdd72c71da0 GIT binary patch literal 132862 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
lPjBPTnu)qE{O-uixAE)8vZv44D&Glh8;1<+w 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 literal 0 HcmV?d00001 diff --git a/packages/engine/src/__tests__/integration/connector-dicom.itest.ts b/packages/engine/src/__tests__/integration/connector-dicom.itest.ts new file mode 100644 index 0000000..b451194 --- /dev/null +++ b/packages/engine/src/__tests__/integration/connector-dicom.itest.ts @@ -0,0 +1,121 @@ +// =========================================== +// DICOM connector cascade — real dcmtk SCU → SCP +// =========================================== +// Channel A: TCP source (message = a .dcm file path) → DICOM destination +// (storescu sends the file via C-STORE). +// Channel B: DICOM source (storescp SCP receives the C-STORE, writes the file) +// → sink. +// Intended to prove the DICOM connectors move a real DICOM object over the wire. +// +// STATUS: opt-in reproducer, NOT part of any default run (gated on +// DICOM_TEST_ENABLED=1; the standard `pnpm test` and `test:integration` runs skip +// it). It currently FAILS at DICOM association negotiation: dcmtk storescu reports +// "Association Rejected: Result: Rejected Permanent, Source: Service User" +// even though the SCP is reachable (it creates the association directory). The +// receiver wrapper (packages/connectors/src/dicom/dicom-receiver.ts +// defaultReceiverFactory) calls DcmtkDicomReceiver.create with no accepted +// SOP-class / presentation-context configuration, so the SCP rejects the SCU's +// proposed context. This is a real DICOM-connector gap to fix; once the SCP +// negotiates a presentation context for the object's SOP class, this test should +// pass unchanged. Kept as a ready reproducer (fixture + harness wiring in place). + +import { it, expect, beforeAll, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + TcpMllpReceiver, + DicomReceiver, + DicomDispatcher, + clearChannelRegistry, +} from '@mirthless/connectors'; +import { deployChannel, teardownAll, CaptureDestination, type DeployedChannel } from '../support/e2e-harness.js'; +import { sendMllp } from '../support/tcp-helpers.js'; +import { describeDicom } from './gates.js'; + +const SCP_PORT = 11112; +const TCP_PORT = 17761; +const SAMPLE_DCM = fileURLToPath(new URL('../fixtures/sample.dcm', import.meta.url)); + +let deployed: DeployedChannel[] = []; +const tempDirs: string[] = []; + +afterEach(async () => { + await teardownAll(deployed); + deployed = []; + for (const d of tempDirs.splice(0)) await fs.rm(d, { recursive: true, force: true }); + clearChannelRegistry(); +}); + +describeDicom('DICOM connector cascade (real dcmtk SCU → SCP)', () => { + beforeAll(async () => { + // Fail loudly if the vendored fixture is missing. + await fs.access(SAMPLE_DCM); + }); + + it('Channel A stores a DICOM object to Channel B\'s SCP over C-STORE', async () => { + const storageDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mless-dicom-')); + tempDirs.push(storageDir); + + const sink = new CaptureDestination(); + + // Channel B: DICOM SCP source → sink (content = received file path). + const channelB = await deployChannel({ + channelId: '00000000-0000-0000-0000-conndicom00b', + dataType: 'RAW', + source: new DicomReceiver({ + port: SCP_PORT, + storageDir, + aeTitle: 'TESTSCP', + minPoolSize: 1, + maxPoolSize: 2, + connectionTimeoutMs: 15_000, + dispatchMode: 'PER_FILE', + postAction: 'NONE', + moveToDirectory: '', + }), + destinations: [{ metaDataId: 1, name: 'sink', connector: sink }], + }); + + // Channel A: TCP source (message = the .dcm path) → DICOM SCU destination. + const channelA = await deployChannel({ + channelId: '00000000-0000-0000-0000-conndicom00a', + dataType: 'RAW', + source: new TcpMllpReceiver({ host: '127.0.0.1', port: TCP_PORT, maxConnections: 10 }), + destinations: [{ + metaDataId: 1, + name: 'DICOM Out', + connector: new DicomDispatcher({ + host: '127.0.0.1', + port: SCP_PORT, + calledAETitle: 'TESTSCP', + callingAETitle: 'TESTSCU', + mode: 'single', + maxAssociations: 1, + maxRetries: 0, + retryDelayMs: 0, + timeoutMs: 20_000, + allowedBaseDir: path.dirname(SAMPLE_DCM), + }), + }], + }); + deployed.push(channelA, channelB); + + // The message content IS the .dcm file path the SCU should send. + await sendMllp(TCP_PORT, SAMPLE_DCM, 20_000); + + // Wait for the C-STORE association to complete and the SCP to dispatch. + for (let i = 0; i < 400 && sink.received.length === 0; i++) { + await new Promise((r) => setTimeout(r, 50)); + } + + expect(sink.received.length).toBeGreaterThanOrEqual(1); + const receivedPath = sink.lastContent() ?? ''; + const stat = await fs.stat(receivedPath); + expect(stat.size).toBeGreaterThan(0); + // The received file is a real DICOM object (starts with the 128-byte preamble + "DICM"). + const head = await fs.readFile(receivedPath); + expect(head.subarray(128, 132).toString('ascii')).toBe('DICM'); + }, 60_000); +}); diff --git a/packages/engine/src/__tests__/integration/gates.ts b/packages/engine/src/__tests__/integration/gates.ts index 4475613..b2fee5a 100644 --- a/packages/engine/src/__tests__/integration/gates.ts +++ b/packages/engine/src/__tests__/integration/gates.ts @@ -108,3 +108,11 @@ export function requireMail(): TestMailConfig { if (!mailConfig) throw new Error('SMTP_TEST_HOST is not set'); return mailConfig; } + +// ----- DICOM (native @ubercode/dcmtk; opt-in via DICOM_TEST_ENABLED) ----- +// The DICOM connectors spawn real dcmtk storescp/storescu binaries. Gated behind +// an explicit flag because native DICOM associations are heavier and slower than +// the other suites. + +export const dicomEnabled = process.env.DICOM_TEST_ENABLED === '1'; +export const describeDicom = dicomEnabled ? describe : describe.skip; From 4b1e35cbf4ce33096fb69e38ed827cf027467b2d Mon Sep 17 00:00:00 2001 From: Michael Hobbs Date: Tue, 14 Jul 2026 23:48:53 -0400 Subject: [PATCH 11/11] =?UTF-8?q?feat(connectors):=20port=20DICOM=20to=20d?= =?UTF-8?q?cmjs-dimse=20(fixes=20association=20rejection)=20=E2=86=92=2011?= =?UTF-8?q?/11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DICOM SCP rejected every C-STORE association ("Rejected Permanent, Source: Service User") because the @ubercode/dcmtk wrapper created the receiver with no accepted presentation contexts — a DICOM SCP must inspect the requested contexts and accept the SOP-class + transfer-syntax pairs it supports before accepting the association. Swap to dcmjs-dimse (pure JS — the same library the production MedFusion DIMSE service uses): - dcmjs-dimse-adapter.ts: DimseReceiver (Scp that negotiates presentation contexts — accept all StorageClass SOP classes + Verification + common transfer syntaxes, per MedFusion's scp.ts/presentation-contexts.ts — writes each received instance to a .dcm via Dataset.toFile) and DimseSender (Client + CStoreRequest(path)). - Drop-in behind the existing DcmtkReceiver/DcmtkSender factory seams: no connector API/config change; the 39 mock-injecting DICOM unit tests are untouched. - Drop @ubercode/dcmtk (native binaries, process spawn, Windows path issues); add dcmjs-dimse. Library loglevel set to warn to suppress protocol chatter. The DICOM SCU→SCP cascade test moves from an opt-in native reproducer to the DEFAULT lane (in-process, no binaries): connector-dicom.e2e.test.ts. That completes real-message E2E for all 11 connector types. connectors 491 + engine 383 passing; build + lint clean. Docs: CHANGELOG + D-181. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01F1ad6jb5mCYxzFVXHbksqi --- docs/progress/CHANGELOG.md | 27 ++ docs/progress/DECISIONS.md | 2 + packages/connectors/package.json | 2 +- .../src/dicom/dcmjs-dimse-adapter.ts | 310 ++++++++++++++++++ .../connectors/src/dicom/dicom-dispatcher.ts | 35 +- .../connectors/src/dicom/dicom-receiver.ts | 33 +- ...m.itest.ts => connector-dicom.e2e.test.ts} | 53 +-- .../engine/src/__tests__/integration/gates.ts | 9 +- pnpm-lock.yaml | 156 +++++++-- 9 files changed, 491 insertions(+), 136 deletions(-) create mode 100644 packages/connectors/src/dicom/dcmjs-dimse-adapter.ts rename packages/engine/src/__tests__/{integration/connector-dicom.itest.ts => connector-dicom.e2e.test.ts} (58%) diff --git a/docs/progress/CHANGELOG.md b/docs/progress/CHANGELOG.md index 580e91d..35fdb6b 100644 --- a/docs/progress/CHANGELOG.md +++ b/docs/progress/CHANGELOG.md @@ -2,6 +2,33 @@ > Session-by-session log of what was built. Enables any future Claude instance to pick up where we left off. +## 2026-07-14 — Real-message E2E testing + DICOM dcmjs-dimse port (branch `feature/real-e2e-testing`) + +Replaced mock-heavy connector tests with a harness that actually pushes messages through real +connectors and asserts what lands on the other side. **All 11 connector types now covered by a +passing real-message E2E.** + +- **E2E harness** (`packages/engine/src/__tests__/support/`) — `deployChannel(spec)` assembles a + live channel (sandbox + 8-stage pipeline + real source/dest connectors) over an in-memory store; + filter/transformer/response-transformer scripts compile through the real esbuild+template path. + `CaptureDestination` sink + MLLP TCP helpers. Two lanes: default `pnpm test` (no infra) and + `test:integration` (`*.itest.ts`, env-gated via `integration/gates.ts`; docker `--profile test` + adds atmoz/sftp + GreenMail). +- **Coverage**: TCP/MLLP + Channel (A→B→C HL7→JSON→XML cascade), File, HTTP, JavaScript (src+dest), + Database (real `_test` PG), SFTP (listen↔dest cascade), SMTP+IMAP (GreenMail cascade), FHIR (dest), + DICOM (SCU→SCP cascade). +- **DICOM connector ported to `dcmjs-dimse`** (pure JS) from `@ubercode/dcmtk` (native binaries). + Root cause of the prior association rejection: the dcmtk wrapper created the SCP with no accepted + presentation contexts. New `dcmjs-dimse-adapter.ts` negotiates contexts (accept all StorageClass + SOP classes + Verification + common transfer syntaxes) — pattern lifted from the production + MedFusion DIMSE service. Drop-in behind the existing `DcmtkReceiver`/`DcmtkSender` factory seams; + no connector API change. DICOM cascade now runs in the default lane, in-process, no binaries. (D-181) +- **TypeScript channel scripts** compiled+run end-to-end; shipped ambient types + `packages/engine/sandbox-globals.d.ts` (drift-guarded against the web Monaco string). +- **Bug fix** (`4c6af8f`): `ChannelService.create` silently dropped `input.transformers`/`filters` + (only `update` persisted them) — data loss on clone/import/programmatic create. Extracted shared + `syncFilters`/`syncTransformers`, wired into both paths; real-Postgres regression test. + ## 2026-07-13 — Dashboard/Channels/Messages overhaul (branch `feature/dashboard-overhaul`) Reworked the three main views so everything happens from the Dashboard; the standalone diff --git a/docs/progress/DECISIONS.md b/docs/progress/DECISIONS.md index 2b0d9e7..e23f854 100644 --- a/docs/progress/DECISIONS.md +++ b/docs/progress/DECISIONS.md @@ -357,3 +357,5 @@ D-178: dbQuery script bridge = named Data Sources, not script-supplied connectio D-179: Fixed migration `when`-timestamp ordering so drizzle-kit applies 0009/0010 (and future migrations). Root cause: the hand-written migrations 0007/0008 were registered in `meta/_journal.json` with future-dated round `when` values (1784000000000 / 1784000100000 ≈ 2026-07-14), while 0009 (collections) and 0010 (data_sources) were generated 2026-07-13 with real lower `Date.now()` values. `drizzle-kit migrate` applies journal entries in `when` order and skips any whose `when` ≤ the last-applied — so once 0008 was applied, 0009/0010 were silently skipped (migrate reported "success" as a no-op). Effect: a **fresh install** created neither `collections` nor `data_sources`, and the dev DB was missing `data_sources` (the Data Sources page 500'd on "relation does not exist"). Fix: bumped 0009→1784000200000 and 0010→1784000300000 in the journal so all `when`s are monotonic by idx (verified: a from-scratch `drizzle-kit migrate` now creates collections + data_sources). Reconciled the dev DB by updating the already-applied 0009 record's `created_at` to match, then `db:migrate` applied 0010 cleanly. LESSON: never hand-set a migration's journal `when` to a future timestamp — a later generated migration will get a lower real timestamp and be skipped. Since it is now past those dates, newly generated migrations sort correctly again; this was a one-time skew affecting only 0009/0010. — 2026-07-14 (branch fix/migration-timestamp-ordering) D-180: RBAC now resolves permissions LIVE from the user's role (single source of truth), replacing the per-user user_permissions snapshot. Root cause of the recurring bug (new permissions like collections:*/datasources:* never reaching existing users, requiring a re-seed): permissions were snapshotted into user_permissions only at user create / role-change, but the enforcement/login paths READ that stale snapshot. Meanwhile UserService.getPermissions already resolved live — two divergent truths. Fix: all four readers (auth.middleware authenticate + optionalAuthenticate, auth.service login, socket.ts) now call `permissionNamesForRole(user.role)` (the same resolver getPermissions uses); removed the dead write path `syncPermissionsForRole` from UserService (create/update no longer touch user_permissions). Effect: adding a permission to a role in code reaches every existing user of that role on their next request — no re-seed, no re-login for server enforcement. The `user_permissions` table is now vestigial (kept to avoid a migration this pass; slated for a drop-migration + scope-column cleanup in RBAC Round 2). Chosen over snapshot+reconcile-on-login (user's call) because one source of truth eliminates the whole staleness class. This is the P0 security/correctness fix; admin-managed role CRUD (roles-as-data) and sub-resource ACLs are deferred to RBAC Round 2 per D-audit plan. — 2026-07-14 (branch feature/quality-pass-1) + +D-181: DICOM connector runs on `dcmjs-dimse` (pure JS), not `@ubercode/dcmtk` (native binaries). Root cause of the DICOM SCP rejecting every C-STORE association ("Rejected Permanent, Source: Service User"): the dcmtk wrapper (`defaultReceiverFactory`) called `DicomReceiver.create` with NO accepted presentation contexts, so the SCP could not negotiate a context for the sender's proposed SOP class + transfer syntax. A DICOM SCP MUST inspect `association.getPresentationContexts()` and `setResult(Accept, transferSyntax)` for the ones it supports before `sendAssociationAccept()`. The native wrapper gave us no seam to do that; it also spawned processes (Windows path issues, not testable in-process). Decision: swap to `dcmjs-dimse` (the same library the production MedFusion DIMSE service uses at `vns/medfusion`), implementing a `DimseReceiver`/`DimseSender` adapter (`packages/connectors/src/dicom/dcmjs-dimse-adapter.ts`) that negotiates presentation contexts (accept all `StorageClass` SOP classes + Verification + common transfer syntaxes, mirroring MedFusion's `presentation-contexts.ts`/`scp.ts`), writes each received instance to a `.dcm` via `Dataset.toFile`, and sends via `Client`+`CStoreRequest(filePath)`. The swap is a drop-in behind the existing injectable `DcmtkReceiver`/`DcmtkSender` factory interfaces (D-086) — no connector API/config change, existing mock-injecting unit tests untouched. Result: the DICOM SCU→SCP cascade now passes in-process in the DEFAULT test lane (no native binaries, no external SCP), completing 11/11 connectors under real-message E2E. `dcmjs-dimse`'s loglevel is set to `warn` to suppress per-association protocol chatter. Chosen over patching dcmtk (we don't control its create() options; still native + spawn) or leaving DICOM as a documented reproducer (10/11). — 2026-07-14 (branch feature/real-e2e-testing) diff --git a/packages/connectors/package.json b/packages/connectors/package.json index 0fa6585..6fe4b40 100644 --- a/packages/connectors/package.json +++ b/packages/connectors/package.json @@ -23,7 +23,7 @@ "@mirthless/core-models": "workspace:*", "@mirthless/core-util": "workspace:*", "@mirthless/engine": "workspace:*", - "@ubercode/dcmtk": "^0.3.0", + "dcmjs-dimse": "^0.3.2", "generic-pool": "^3.9.0", "imapflow": "^1.0.171", "nodemailer": "^9.0.1", diff --git a/packages/connectors/src/dicom/dcmjs-dimse-adapter.ts b/packages/connectors/src/dicom/dcmjs-dimse-adapter.ts new file mode 100644 index 0000000..1213d06 --- /dev/null +++ b/packages/connectors/src/dicom/dcmjs-dimse-adapter.ts @@ -0,0 +1,310 @@ +// =========================================== +// dcmjs-dimse adapter for the DICOM connector +// =========================================== +// Pure-JS DICOM DIMSE backing for the DICOM source (SCP / C-STORE receive) and +// destination (SCU / C-STORE send), replacing the native @ubercode/dcmtk wrapper. +// +// The critical piece the native wrapper lacked is association negotiation: an SCP +// must inspect the requested presentation contexts and ACCEPT the ones whose +// abstract syntax (SOP class) and a transfer syntax it supports, otherwise the +// association is rejected ("Source: Service User"). The accept-list here mirrors +// the production MedFusion DIMSE service (packages/dicom + apps/dimse). + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { Result } from '@mirthless/core-util'; +import pkg from 'dcmjs-dimse'; +import type { DcmtkReceiver, DcmtkFileData, DcmtkAssociationData } from './dicom-receiver.js'; +import type { DcmtkSender, DcmtkSendResult } from './dicom-dispatcher.js'; + +const { Server, Scp, Client, Dataset, constants, requests, responses, log } = pkg; +const { SopClass, StorageClass, TransferSyntax, PresentationContextResult, Status } = constants; +const { CStoreRequest } = requests; +const { CEchoResponse, CStoreResponse } = responses; + +// Silence dcmjs-dimse's per-association protocol chatter (loglevel INFO); the +// connector surfaces failures through its own ConnectorLogger. Warnings/errors +// from the library still pass through. +log.setLevel('warn'); + +type AssociationType = InstanceType; +type CStoreRequestType = InstanceType; +type CStoreResponseType = InstanceType; +type CEchoRequestType = InstanceType; +type CEchoResponseType = InstanceType; +type DatasetType = InstanceType; +type ServerType = InstanceType; + +function ok(value: T): Result { + return { ok: true, value, error: null } as Result; +} +function fail(error: Error): Result { + return { ok: false, value: null, error } as unknown as Result; +} + +// ----- Accepted presentation contexts ----- + +const ACCEPTED_TRANSFER_SYNTAXES: ReadonlySet = new Set([ + TransferSyntax.ImplicitVRLittleEndian, + TransferSyntax.ExplicitVRLittleEndian, + TransferSyntax.ExplicitVRBigEndian, + TransferSyntax.DeflatedExplicitVRLittleEndian, + TransferSyntax.RleLossless, + TransferSyntax.JpegBaseline, + TransferSyntax.JpegLossless, + TransferSyntax.JpegLsLossless, + TransferSyntax.JpegLsLossy, + TransferSyntax.Jpeg2000Lossless, + TransferSyntax.Jpeg2000Lossy, +]); + +// All storage SOP classes plus Verification (C-ECHO). The channel connector only +// stores, so we accept everything storable rather than a narrow modality list. +const ACCEPTED_ABSTRACT_SYNTAXES: ReadonlySet = new Set([ + SopClass.Verification, + ...Object.values(StorageClass) as string[], +]); + +/** Negotiate presentation contexts on an inbound association: accept storable SOP classes. */ +function negotiatePresentationContexts(association: AssociationType): void { + const contexts = association.getPresentationContexts(); + for (const { id } of contexts) { + const context = association.getPresentationContext(id); + const abstractSyntax = context.getAbstractSyntaxUid(); + if (!ACCEPTED_ABSTRACT_SYNTAXES.has(abstractSyntax)) { + context.setResult(PresentationContextResult.RejectAbstractSyntaxNotSupported); + continue; + } + let accepted = false; + for (const ts of context.getTransferSyntaxUids()) { + if (ACCEPTED_TRANSFER_SYNTAXES.has(ts)) { + context.setResult(PresentationContextResult.Accept, ts); + accepted = true; + break; + } + } + if (!accepted) { + context.setResult(PresentationContextResult.RejectTransferSyntaxesNotSupported); + } + } +} + +// ----- SCP receiver ----- + +interface ReceiverOptions { + readonly port: number; + readonly storageDir: string; + readonly aeTitle: string; + readonly connectionTimeoutMs: number; +} + +class DimseReceiver implements DcmtkReceiver { + private server: ServerType | null = null; + private fileListener: ((data: DcmtkFileData) => void) | null = null; + private assocListener: ((data: DcmtkAssociationData) => void) | null = null; + private errorListener: ((data: { readonly error: Error }) => void) | null = null; + + constructor(private readonly options: ReceiverOptions) {} + + onFileReceived(listener: (data: DcmtkFileData) => void): void { this.fileListener = listener; } + onAssociationComplete(listener: (data: DcmtkAssociationData) => void): void { this.assocListener = listener; } + onEvent(_event: 'error', listener: (data: { readonly error: Error }) => void): void { this.errorListener = listener; } + + /** Persist a received dataset to a .dcm file and notify listeners. */ + private handleInstance(dataset: DatasetType, callingAe: string, calledAe: string, assocId: string, files: string[]): void { + const elements = dataset.getElements() as Record; + const sopInstanceUid = typeof elements['SOPInstanceUID'] === 'string' && elements['SOPInstanceUID'] + ? (elements['SOPInstanceUID'] as string) + : Dataset.generateDerivedUid(); + fs.mkdirSync(this.options.storageDir, { recursive: true }); + const filePath = path.join(this.options.storageDir, `${sopInstanceUid}.dcm`); + dataset.toFile(filePath); + files.push(filePath); + this.fileListener?.({ + filePath, + associationId: assocId, + associationDir: this.options.storageDir, + callingAE: callingAe, + calledAE: calledAe, + source: 'dcmjs-dimse', + instance: { dataset: elements }, + }); + } + + async start(): Promise> { + // Capture just what the per-connection Scp needs, to avoid aliasing `this`. + const storageDir = this.options.storageDir; + const handleInstance = this.handleInstance.bind(this); + const emitAssociation = (data: DcmtkAssociationData): void => { this.assocListener?.(data); }; + const emitError = (error: Error): void => { this.errorListener?.({ error }); }; + let assocCounter = 0; + + class ConnectorScp extends Scp { + private callingAe = ''; + private calledAe = ''; + private readonly assocId = `assoc-${String(++assocCounter)}`; + private readonly files: string[] = []; + private readonly startedAt = Date.now(); + + override associationRequested(association: AssociationType): void { + this.callingAe = association.getCallingAeTitle(); + this.calledAe = association.getCalledAeTitle(); + negotiatePresentationContexts(association); + this.sendAssociationAccept(); + } + + override associationReleaseRequested(): void { + emitAssociation({ + associationId: this.assocId, + associationDir: storageDir, + callingAE: this.callingAe, + calledAE: this.calledAe, + source: 'dcmjs-dimse', + files: [...this.files], + durationMs: Date.now() - this.startedAt, + }); + this.sendAssociationReleaseResponse(); + } + + override cEchoRequest(request: CEchoRequestType, callback: (response: CEchoResponseType) => void): void { + const response = CEchoResponse.fromRequest(request); + response.setStatus(Status.Success); + callback(response); + } + + override cStoreRequest(request: CStoreRequestType, callback: (response: CStoreResponseType) => void): void { + const response = CStoreResponse.fromRequest(request); + try { + const dataset = request.getDataset(); + if (!dataset) { + response.setStatus(Status.ProcessingFailure); + callback(response); + return; + } + handleInstance(dataset, this.callingAe, this.calledAe, this.assocId, this.files); + response.setStatus(Status.Success); + } catch (err) { + emitError(err instanceof Error ? err : new Error(String(err))); + response.setStatus(Status.ProcessingFailure); + } + callback(response); + } + } + + try { + const server = new Server(ConnectorScp as unknown as typeof Scp); + server.on('networkError', (e: Error) => { this.errorListener?.({ error: e }); }); + server.listen(this.options.port, { + connectTimeout: this.options.connectionTimeoutMs, + associationTimeout: this.options.connectionTimeoutMs, + pduTimeout: this.options.connectionTimeoutMs, + }); + this.server = server; + return ok(undefined); + } catch (err) { + return fail(err instanceof Error ? err : new Error(String(err))); + } + } + + async stop(): Promise { + this.server?.close(); + this.server = null; + } +} + +/** Factory: a dcmjs-dimse-backed DICOM SCP receiver. */ +export function createDimseReceiver(options: { + readonly port: number; + readonly storageDir: string; + readonly aeTitle: string; + readonly minPoolSize: number; + readonly maxPoolSize: number; + readonly connectionTimeoutMs: number; +}): Result { + return ok(new DimseReceiver({ + port: options.port, + storageDir: options.storageDir, + aeTitle: options.aeTitle, + connectionTimeoutMs: options.connectionTimeoutMs, + })); +} + +// ----- SCU sender ----- + +interface SenderOptions { + readonly host: string; + readonly port: number; + readonly calledAETitle: string; + readonly callingAETitle: string; + readonly maxRetries: number; + readonly retryDelayMs: number; + readonly timeoutMs: number; +} + +class DimseSender implements DcmtkSender { + constructor(private readonly options: SenderOptions) {} + + private sendOnce(files: readonly string[]): Promise> { + return new Promise((resolve) => { + const startedAt = Date.now(); + const client = new Client(); + for (const file of files) { + client.addRequest(new CStoreRequest(file)); + } + + let rejection: Error | null = null; + client.on('associationRejected', (r: { result: number; source: number; reason: number }) => { + rejection = new Error(`DICOM association rejected: result=${String(r.result)} source=${String(r.source)} reason=${String(r.reason)}`); + }); + client.on('networkError', (e: Error) => { resolve(fail(e)); }); + client.on('closed', () => { + if (rejection) { resolve(fail(rejection)); return; } + resolve(ok({ files, fileCount: files.length, durationMs: Date.now() - startedAt })); + }); + + client.send(this.options.host, this.options.port, this.options.callingAETitle, this.options.calledAETitle, { + connectTimeout: this.options.timeoutMs, + associationTimeout: this.options.timeoutMs, + pduTimeout: this.options.timeoutMs, + }); + }); + } + + async send(files: readonly string[]): Promise> { + let attempt = 0; + let last: Result = fail(new Error('no attempt made')); + for (;;) { + last = await this.sendOnce(files); + if (last.ok || attempt >= this.options.maxRetries) return last; + attempt += 1; + if (this.options.retryDelayMs > 0) { + await new Promise((r) => setTimeout(r, this.options.retryDelayMs)); + } + } + } + + async stop(): Promise { /* clients are created per send */ } +} + +/** Factory: a dcmjs-dimse-backed DICOM SCU sender. */ +export function createDimseSender(options: { + readonly host: string; + readonly port: number; + readonly calledAETitle: string; + readonly callingAETitle: string; + readonly mode: 'single' | 'multiple'; + readonly maxAssociations: number; + readonly maxRetries: number; + readonly retryDelayMs: number; + readonly timeoutMs: number; +}): Result { + return ok(new DimseSender({ + host: options.host, + port: options.port, + calledAETitle: options.calledAETitle, + callingAETitle: options.callingAETitle, + maxRetries: options.maxRetries, + retryDelayMs: options.retryDelayMs, + timeoutMs: options.timeoutMs, + })); +} diff --git a/packages/connectors/src/dicom/dicom-dispatcher.ts b/packages/connectors/src/dicom/dicom-dispatcher.ts index 0efc6f1..b9064ce 100644 --- a/packages/connectors/src/dicom/dicom-dispatcher.ts +++ b/packages/connectors/src/dicom/dicom-dispatcher.ts @@ -1,12 +1,13 @@ // =========================================== // DICOM Dispatcher (Destination Connector) // =========================================== -// Wraps @ubercode/dcmtk DicomSender to send DICOM files via C-STORE. +// A dcmjs-dimse DICOM SCU that sends DICOM files via C-STORE. // Expects message content to be a file path. import * as path from 'node:path'; import { tryCatch, type Result } from '@mirthless/core-util'; import type { DestinationConnectorRuntime, ConnectorMessage, ConnectorResponse } from '../base.js'; +import { createDimseSender } from './dcmjs-dimse-adapter.js'; // ----- Config ----- @@ -75,7 +76,7 @@ export class DicomDispatcher implements DestinationConnectorRuntime { constructor(config: DicomDispatcherConfig, createSender?: SenderFactory) { this.config = config; - this.createSender = createSender ?? defaultSenderFactory; + this.createSender = createSender ?? createDimseSender; } async onDeploy(): Promise> { @@ -178,34 +179,4 @@ export class DicomDispatcher implements DestinationConnectorRuntime { } } -// ----- Default factory (uses @ubercode/dcmtk) ----- -function defaultSenderFactory(options: { - readonly host: string; - readonly port: number; - readonly calledAETitle: string; - readonly callingAETitle: string; - readonly mode: 'single' | 'multiple'; - readonly maxAssociations: number; - readonly maxRetries: number; - readonly retryDelayMs: number; - readonly timeoutMs: number; -}): Result { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { DicomSender: DcmtkDicomSender } = require('@ubercode/dcmtk') as { - DicomSender: { - create(opts: Record): Result; - }; - }; - return DcmtkDicomSender.create({ - host: options.host, - port: options.port, - calledAETitle: options.calledAETitle, - callingAETitle: options.callingAETitle, - mode: options.mode, - maxAssociations: options.maxAssociations, - maxRetries: options.maxRetries, - retryDelayMs: options.retryDelayMs, - timeoutMs: options.timeoutMs, - }); -} diff --git a/packages/connectors/src/dicom/dicom-receiver.ts b/packages/connectors/src/dicom/dicom-receiver.ts index 7a874ac..0b975ad 100644 --- a/packages/connectors/src/dicom/dicom-receiver.ts +++ b/packages/connectors/src/dicom/dicom-receiver.ts @@ -1,8 +1,8 @@ // =========================================== // DICOM Receiver (Source Connector) // =========================================== -// Wraps @ubercode/dcmtk DicomReceiver to receive DICOM C-STORE -// associations and dispatch each file as a message into the pipeline. +// A dcmjs-dimse DICOM SCP that receives C-STORE associations and dispatches each +// received instance as a message into the pipeline. // Content = file path; metadata goes into sourceMap. import * as fs from 'node:fs/promises'; @@ -10,6 +10,7 @@ import * as path from 'node:path'; import { tryCatch, type Result } from '@mirthless/core-util'; import type { SourceConnectorRuntime, MessageDispatcher, RawMessage } from '../base.js'; import { createConnectorLogger, errorInfo, type ConnectorLogger } from '../logger.js'; +import { createDimseReceiver } from './dcmjs-dimse-adapter.js'; // ----- Constants ----- @@ -99,7 +100,7 @@ export class DicomReceiver implements SourceConnectorRuntime { constructor(config: DicomReceiverConfig, createReceiver?: ReceiverFactory) { this.config = config; - this.createReceiver = createReceiver ?? defaultReceiverFactory; + this.createReceiver = createReceiver ?? createDimseReceiver; } setDispatcher(dispatcher: MessageDispatcher): void { @@ -275,29 +276,3 @@ export class DicomReceiver implements SourceConnectorRuntime { } } -// ----- Default factory (uses @ubercode/dcmtk) ----- - -function defaultReceiverFactory(options: { - readonly port: number; - readonly storageDir: string; - readonly aeTitle: string; - readonly minPoolSize: number; - readonly maxPoolSize: number; - readonly connectionTimeoutMs: number; -}): Result { - // Dynamic import-style require to avoid bundling issues - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { DicomReceiver: DcmtkDicomReceiver } = require('@ubercode/dcmtk') as { - DicomReceiver: { - create(opts: Record): Result; - }; - }; - return DcmtkDicomReceiver.create({ - port: options.port, - storageDir: options.storageDir, - aeTitle: options.aeTitle, - minPoolSize: options.minPoolSize, - maxPoolSize: options.maxPoolSize, - connectionTimeoutMs: options.connectionTimeoutMs, - }); -} diff --git a/packages/engine/src/__tests__/integration/connector-dicom.itest.ts b/packages/engine/src/__tests__/connector-dicom.e2e.test.ts similarity index 58% rename from packages/engine/src/__tests__/integration/connector-dicom.itest.ts rename to packages/engine/src/__tests__/connector-dicom.e2e.test.ts index b451194..6fd1f17 100644 --- a/packages/engine/src/__tests__/integration/connector-dicom.itest.ts +++ b/packages/engine/src/__tests__/connector-dicom.e2e.test.ts @@ -1,25 +1,14 @@ // =========================================== -// DICOM connector cascade — real dcmtk SCU → SCP +// DICOM connector cascade — dcmjs-dimse SCU → SCP (in-process, pure JS) // =========================================== // Channel A: TCP source (message = a .dcm file path) → DICOM destination -// (storescu sends the file via C-STORE). -// Channel B: DICOM source (storescp SCP receives the C-STORE, writes the file) -// → sink. -// Intended to prove the DICOM connectors move a real DICOM object over the wire. -// -// STATUS: opt-in reproducer, NOT part of any default run (gated on -// DICOM_TEST_ENABLED=1; the standard `pnpm test` and `test:integration` runs skip -// it). It currently FAILS at DICOM association negotiation: dcmtk storescu reports -// "Association Rejected: Result: Rejected Permanent, Source: Service User" -// even though the SCP is reachable (it creates the association directory). The -// receiver wrapper (packages/connectors/src/dicom/dicom-receiver.ts -// defaultReceiverFactory) calls DcmtkDicomReceiver.create with no accepted -// SOP-class / presentation-context configuration, so the SCP rejects the SCU's -// proposed context. This is a real DICOM-connector gap to fix; once the SCP -// negotiates a presentation context for the object's SOP class, this test should -// pass unchanged. Kept as a ready reproducer (fixture + harness wiring in place). +// (C-STORE SCU sends the file). +// Channel B: DICOM source (C-STORE SCP receives it, writes a .dcm) → sink. +// Proves the DICOM connectors move a real DICOM object over the wire between two +// channels. Pure JavaScript (dcmjs-dimse) — no native binaries, no external +// server — so it runs in the default lane like the other connectors. -import { it, expect, beforeAll, afterEach } from 'vitest'; +import { describe, it, expect, afterEach } from 'vitest'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -30,13 +19,12 @@ import { DicomDispatcher, clearChannelRegistry, } from '@mirthless/connectors'; -import { deployChannel, teardownAll, CaptureDestination, type DeployedChannel } from '../support/e2e-harness.js'; -import { sendMllp } from '../support/tcp-helpers.js'; -import { describeDicom } from './gates.js'; +import { deployChannel, teardownAll, CaptureDestination, type DeployedChannel } from './support/e2e-harness.js'; +import { sendMllp } from './support/tcp-helpers.js'; -const SCP_PORT = 11112; -const TCP_PORT = 17761; -const SAMPLE_DCM = fileURLToPath(new URL('../fixtures/sample.dcm', import.meta.url)); +const SCP_PORT = 11114; +const TCP_PORT = 17762; +const SAMPLE_DCM = fileURLToPath(new URL('./fixtures/sample.dcm', import.meta.url)); let deployed: DeployedChannel[] = []; const tempDirs: string[] = []; @@ -48,12 +36,7 @@ afterEach(async () => { clearChannelRegistry(); }); -describeDicom('DICOM connector cascade (real dcmtk SCU → SCP)', () => { - beforeAll(async () => { - // Fail loudly if the vendored fixture is missing. - await fs.access(SAMPLE_DCM); - }); - +describe('DICOM connector cascade (dcmjs-dimse SCU → SCP)', () => { it('Channel A stores a DICOM object to Channel B\'s SCP over C-STORE', async () => { const storageDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mless-dicom-')); tempDirs.push(storageDir); @@ -105,17 +88,15 @@ describeDicom('DICOM connector cascade (real dcmtk SCU → SCP)', () => { // The message content IS the .dcm file path the SCU should send. await sendMllp(TCP_PORT, SAMPLE_DCM, 20_000); - // Wait for the C-STORE association to complete and the SCP to dispatch. for (let i = 0; i < 400 && sink.received.length === 0; i++) { - await new Promise((r) => setTimeout(r, 50)); + await new Promise((r) => setTimeout(r, 25)); } expect(sink.received.length).toBeGreaterThanOrEqual(1); const receivedPath = sink.lastContent() ?? ''; - const stat = await fs.stat(receivedPath); - expect(stat.size).toBeGreaterThan(0); - // The received file is a real DICOM object (starts with the 128-byte preamble + "DICM"). const head = await fs.readFile(receivedPath); + // The received file is a real DICOM object: 128-byte preamble + "DICM". expect(head.subarray(128, 132).toString('ascii')).toBe('DICM'); - }, 60_000); + expect(head.length).toBeGreaterThan(1000); + }, 45_000); }); diff --git a/packages/engine/src/__tests__/integration/gates.ts b/packages/engine/src/__tests__/integration/gates.ts index b2fee5a..71731e0 100644 --- a/packages/engine/src/__tests__/integration/gates.ts +++ b/packages/engine/src/__tests__/integration/gates.ts @@ -109,10 +109,5 @@ export function requireMail(): TestMailConfig { return mailConfig; } -// ----- DICOM (native @ubercode/dcmtk; opt-in via DICOM_TEST_ENABLED) ----- -// The DICOM connectors spawn real dcmtk storescp/storescu binaries. Gated behind -// an explicit flag because native DICOM associations are heavier and slower than -// the other suites. - -export const dicomEnabled = process.env.DICOM_TEST_ENABLED === '1'; -export const describeDicom = dicomEnabled ? describe : describe.skip; +// (DICOM needs no gate: the dcmjs-dimse connector runs in-process, so its cascade +// test lives in the default lane — packages/engine/src/__tests__/connector-dicom.e2e.test.ts.) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 302611d..28fcfa3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,9 +82,9 @@ importers: '@mirthless/engine': specifier: workspace:* version: link:../engine - '@ubercode/dcmtk': - specifier: ^0.3.0 - version: 0.3.0 + dcmjs-dimse: + specifier: ^0.3.2 + version: 0.3.3 generic-pool: specifier: ^3.9.0 version: 3.9.0 @@ -508,6 +508,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime-corejs3@7.29.7': + resolution: {integrity: sha512-ppj9ouYku+RX0ljtgZd+KMO5mkM2bCqg8H2PYAFWnLsHEIKIdRojqbJ2i3eVHrisuxy7nOFCmngTDdWtUCdXUQ==} + engines: {node: '>=6.9.0'} + '@babel/runtime@7.28.6': resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} @@ -2044,10 +2048,6 @@ packages: resolution: {integrity: sha512-zORcwn4C3trOWiCqFQP1x6G3xTRyZ1LYydnj51cRnJ6hxBlr/cKPckk+PKPUw/fXmvfKTcw7bwY3w9izgx5jZw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@ubercode/dcmtk@0.3.0': - resolution: {integrity: sha512-H+ZduKCt1AViTOZzxdpkeTjpSeWmUEfWKm+ty6AcZNcyuPmAU0y7S/etgMpecdf7ExLTUspOlDFg0O4zuJxFgg==} - engines: {node: '>=20'} - '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -2112,6 +2112,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adm-zip@0.5.18: + resolution: {integrity: sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==} + engines: {node: '>=12.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -2185,10 +2189,16 @@ packages: ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + async-event-emitter2@0.0.2: + resolution: {integrity: sha512-STaByrQCwDlQEb1stntYzVYzaS3zA02+3OMm6Ah6hjUWhmmthS0aJaD5t9GGQ/Q8H9vHl8O11PUm9s+z3bNUdQ==} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -2384,6 +2394,9 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + core-js-pure@3.49.0: + resolution: {integrity: sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==} + cors@2.8.5: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} @@ -2433,6 +2446,15 @@ packages: dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + dcmjs-codecs@0.0.8: + resolution: {integrity: sha512-qYilc+kqqpLYha6wytkIjzqsiByg7ol3KbONcJMcN7yG6G3FJOJo2s0PqtaCMM4sw9Ilcr9IyPgkKWW+tfuKQA==} + + dcmjs-dimse@0.3.3: + resolution: {integrity: sha512-u2MpnnydfQ3lZa25Y5UMqUBLRPoKM53SPJdQwvWe6TYJC/3fxgWfV55vJIoS6rN+hKPxVj87TOyM6QdUCClyzw==} + + dcmjs@0.50.3: + resolution: {integrity: sha512-h1bqE8K+JSDHEJTnVw0I/lqVO26zZ4uEwQ/lQ/mrntnCVYCbY+6XnGTgFYQw39PJ4DEDVTBzXfO9kNizYxsnqA==} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -2957,6 +2979,9 @@ packages: github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + gl-matrix@3.4.4: + resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -3093,6 +3118,9 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + iota-array@1.0.0: + resolution: {integrity: sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==} + ip-address@10.2.0: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} @@ -3120,6 +3148,9 @@ packages: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} + is-buffer@1.1.6: + resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} + is-callable@1.2.7: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} @@ -3333,6 +3364,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -3357,6 +3391,13 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + loglevel-plugin-prefix@0.8.4: + resolution: {integrity: sha512-WpG9CcFAOjz/FtNht+QJeGpvVl/cdR6P0z6OcXSkr8wFJOsV2GRj2j10JLfjuA4aYkcKCNIEqRGCyTife9R8/g==} + + loglevel@1.9.2: + resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} + engines: {node: '>= 0.6.0'} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -3405,6 +3446,10 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} + memorystream@0.3.1: + resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} + engines: {node: '>= 0.10.0'} + merge-descriptors@1.0.3: resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} @@ -3485,6 +3530,9 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + ndarray@1.0.19: + resolution: {integrity: sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==} + negotiator@0.6.3: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} @@ -3572,6 +3620,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@2.2.0: + resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -4108,10 +4159,6 @@ packages: resolution: {integrity: sha512-degbccEEXfFHNlg1idQUT/Gy8t6ameS5rzE9etFY95iZHaLK4APZvYqpyX8PtVrmT3CzD+ut8GXKUbRkhBfFWg==} engines: {node: '>=18'} - stderr-lib@2.2.0: - resolution: {integrity: sha512-PI6uehBcOIsDshlCnTpaVog8NylyGe/Jerb7cVYisBLZJ8G2br+B0CO0G0g/DOgTouOXcwsZjzSToXLOBGnE1w==} - engines: {node: '>=18'} - stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -4256,16 +4303,15 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} - tree-kill@1.2.2: - resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true - ts-api-utils@1.4.3: resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} engines: {node: '>=16'} peerDependencies: typescript: '>=4.2.0' + ts-mixer@6.0.4: + resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -4576,9 +4622,6 @@ packages: zod@4.3.4: resolution: {integrity: sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==} - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} - zustand@5.0.11: resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==} engines: {node: '>=12.20.0'} @@ -4701,6 +4744,10 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime-corejs3@7.29.7': + dependencies: + core-js-pure: 3.49.0 + '@babel/runtime@7.28.6': {} '@babel/template@7.28.6': @@ -5834,13 +5881,6 @@ snapshots: '@typescript-eslint/types': 8.18.2 eslint-visitor-keys: 4.2.1 - '@ubercode/dcmtk@0.3.0': - dependencies: - fast-xml-parser: 5.10.0 - stderr-lib: 2.2.0 - tree-kill: 1.2.2 - zod: 4.3.6 - '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@22.10.2)(tsx@4.19.2)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.0 @@ -5932,6 +5972,8 @@ snapshots: acorn@8.16.0: {} + adm-zip@0.5.18: {} + agent-base@7.1.4: {} ajv@6.14.0: @@ -6013,8 +6055,14 @@ snapshots: ast-types-flow@0.0.8: {} + async-event-emitter2@0.0.2: + dependencies: + async: 3.2.6 + async-function@1.0.0: {} + async@3.2.6: {} + asynckit@0.4.0: {} atomic-sleep@1.0.0: {} @@ -6211,6 +6259,8 @@ snapshots: cookie@0.7.2: {} + core-js-pure@3.49.0: {} + cors@2.8.5: dependencies: object-assign: 4.1.1 @@ -6274,6 +6324,33 @@ snapshots: dateformat@4.6.3: {} + dcmjs-codecs@0.0.8: + dependencies: + dcmjs: 0.50.3 + loglevel: 1.9.2 + loglevel-plugin-prefix: 0.8.4 + + dcmjs-dimse@0.3.3: + dependencies: + async-event-emitter2: 0.0.2 + dcmjs: 0.50.3 + dcmjs-codecs: 0.0.8 + loglevel: 1.9.2 + loglevel-plugin-prefix: 0.8.4 + memorystream: 0.3.1 + smart-buffer: 4.2.0 + ts-mixer: 6.0.4 + + dcmjs@0.50.3: + dependencies: + '@babel/runtime-corejs3': 7.29.7 + adm-zip: 0.5.18 + gl-matrix: 3.4.4 + lodash.clonedeep: 4.5.0 + loglevel: 1.9.2 + ndarray: 1.0.19 + pako: 2.2.0 + debug@2.6.9: dependencies: ms: 2.0.0 @@ -6972,6 +7049,8 @@ snapshots: github-from-package@0.0.0: {} + gl-matrix@3.4.4: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -7118,6 +7197,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + iota-array@1.0.0: {} + ip-address@10.2.0: {} ipaddr.js@1.9.1: {} @@ -7147,6 +7228,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-buffer@1.1.6: {} + is-callable@1.2.7: {} is-core-module@2.16.1: @@ -7388,6 +7471,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.clonedeep@4.5.0: {} + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} @@ -7404,6 +7489,10 @@ snapshots: lodash.once@4.1.1: {} + loglevel-plugin-prefix@0.8.4: {} + + loglevel@1.9.2: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -7442,6 +7531,8 @@ snapshots: media-typer@0.3.0: {} + memorystream@0.3.1: {} + merge-descriptors@1.0.3: {} merge2@1.4.1: {} @@ -7501,6 +7592,11 @@ snapshots: natural-compare@1.4.0: {} + ndarray@1.0.19: + dependencies: + iota-array: 1.0.0 + is-buffer: 1.1.6 + negotiator@0.6.3: {} negotiator@0.6.4: {} @@ -7585,6 +7681,8 @@ snapshots: package-json-from-dist@1.0.1: {} + pako@2.2.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -8242,8 +8340,6 @@ snapshots: stderr-lib@2.1.0: {} - stderr-lib@2.2.0: {} - stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -8397,12 +8493,12 @@ snapshots: dependencies: punycode: 2.3.1 - tree-kill@1.2.2: {} - ts-api-utils@1.4.3(typescript@5.7.2): dependencies: typescript: 5.7.2 + ts-mixer@6.0.4: {} + tslib@2.8.1: {} tsx@4.19.2: @@ -8694,8 +8790,6 @@ snapshots: zod@4.3.4: {} - zod@4.3.6: {} - zustand@5.0.11(@types/react@18.3.28)(react@18.3.1): optionalDependencies: '@types/react': 18.3.28