diff --git a/.changeset/service-automation-test-hardening.md b/.changeset/service-automation-test-hardening.md new file mode 100644 index 0000000000..61cd4ee360 --- /dev/null +++ b/.changeset/service-automation-test-hardening.md @@ -0,0 +1,12 @@ +--- +--- + +test(automation): big-loop run-history integration test + fix pre-existing test-file type errors + +Test-only; no runtime/API change (nothing under `src/` behaviour is touched), so +this releases nothing. Adds an end-to-end `run-history.test.ts` case exercising +the region-aware history compaction (#3234) through the real engine +(execute → recordTerminal → restart → getRun) with a >MAX-step loop, and clears +the pre-existing `tsc --noEmit` errors across the package's test files (missing +`maturity`, implicit `any`, `ConnectorProviderFactory` test-double defs, an +unused param, a `{}`-typed output access) so the whole package type-checks clean. diff --git a/packages/services/service-automation/src/builtin/connector-nodes.test.ts b/packages/services/service-automation/src/builtin/connector-nodes.test.ts index 188366e1d8..f9f7fa9695 100644 --- a/packages/services/service-automation/src/builtin/connector-nodes.test.ts +++ b/packages/services/service-automation/src/builtin/connector-nodes.test.ts @@ -193,7 +193,7 @@ describe('AutomationEngine connector registry', () => { inputSchema: { message: { type: 'string' } }, }, ], - } as Connector, + } as unknown as Connector, { async echo() { return {}; } }, ); diff --git a/packages/services/service-automation/src/builtin/nested-composition.test.ts b/packages/services/service-automation/src/builtin/nested-composition.test.ts index 0e1b341135..d62cc3112a 100644 --- a/packages/services/service-automation/src/builtin/nested-composition.test.ts +++ b/packages/services/service-automation/src/builtin/nested-composition.test.ts @@ -183,6 +183,6 @@ describe('nested control-flow composition (ADR-0031)', () => { expect(result.success).toBe(true); // The loop body's mutation of `lastSeen` survived to the flow output — the // region ran in the enclosing scope, last iteration wins. - expect(result.output?.lastSeen).toBe('n:Z'); + expect((result.output as Record | undefined)?.lastSeen).toBe('n:Z'); }); }); diff --git a/packages/services/service-automation/src/connector-descriptor-audit.test.ts b/packages/services/service-automation/src/connector-descriptor-audit.test.ts index 7e378f39c6..0056c13e53 100644 --- a/packages/services/service-automation/src/connector-descriptor-audit.test.ts +++ b/packages/services/service-automation/src/connector-descriptor-audit.test.ts @@ -81,8 +81,8 @@ async function bootWithConnectors( function auditWarnings(warnSpy: ReturnType): string[] { return warnSpy.mock.calls - .map((c) => String(c[0])) - .filter((m) => m.includes('declarative connector')); + .map((c: unknown[]) => String(c[0])) + .filter((m: string) => m.includes('declarative connector')); } describe('findInertDeclaredConnectors (pure contract)', () => { diff --git a/packages/services/service-automation/src/connector-materialization.test.ts b/packages/services/service-automation/src/connector-materialization.test.ts index 1587d5914f..4c38ef8ed5 100644 --- a/packages/services/service-automation/src/connector-materialization.test.ts +++ b/packages/services/service-automation/src/connector-materialization.test.ts @@ -15,6 +15,7 @@ import * as path from 'node:path'; import { describe, it, expect, afterAll, afterEach, vi } from 'vitest'; import { LiteKernel } from '@objectstack/core'; import type { + Connector, ConnectorProviderContext, ConnectorProviderFactory, ConnectorInstanceAuth, @@ -69,7 +70,7 @@ function makeFakeProvider() { description: ctx.description, authentication: { type: 'none' }, actions: [{ key: 'ping', label: 'Ping' }], - }, + } as unknown as Connector, handlers: { ping: async (input: Record) => ({ ok: true, @@ -155,7 +156,7 @@ function makeClosableProvider() { type: 'api', authentication: { type: 'none' }, actions: [{ key: 'ping', label: 'Ping' }], - }, + } as unknown as Connector, handlers: { ping: async () => ({ ok: true }) }, close: async () => { closed.push(ctx.name); }, }; @@ -459,7 +460,7 @@ function makeFileReadingProvider() { const text = await ctx.loadPackageFile!(String(ctx.providerConfig.spec)); reads.push(text); return { - def: { name: ctx.name, label: ctx.label, type: 'api', authentication: { type: 'none' }, actions: [{ key: 'ping', label: 'Ping' }] }, + def: { name: ctx.name, label: ctx.label, type: 'api', authentication: { type: 'none' }, actions: [{ key: 'ping', label: 'Ping' }] } as unknown as Connector, handlers: { ping: async () => ({ ok: true }) }, }; }; @@ -573,7 +574,7 @@ function makeFlakyUpstreamProvider(opts: { down: boolean }) { type: 'api', authentication: { type: 'none' }, actions: [{ key: 'ping', label: 'Ping' }], - }, + } as unknown as Connector, handlers: { ping: async () => ({ ok: true }) }, close: async () => { closed.push(ctx.name); }, }; diff --git a/packages/services/service-automation/src/engine.test.ts b/packages/services/service-automation/src/engine.test.ts index 95ee9f6418..dc7b92fe8d 100644 --- a/packages/services/service-automation/src/engine.test.ts +++ b/packages/services/service-automation/src/engine.test.ts @@ -173,7 +173,7 @@ describe('AutomationEngine', () => { it('should execute nodes and collect output', async () => { engine.registerNodeExecutor({ type: 'assignment', - async execute(node, variables) { + async execute(_node, variables) { variables.set('result', 42); return { success: true }; }, @@ -2242,6 +2242,7 @@ describe('Action Descriptor Registry (ADR-0018)', () => { isAsync: false, source: 'plugin', deprecated: false, + maturity: 'ga', }, async execute() { return { success: true }; }, }); @@ -2262,7 +2263,7 @@ describe('Action Descriptor Registry (ADR-0018)', () => { type: 'send_sms', version: '1.0.0', name: 'Send SMS', category: 'io', paradigms: ['flow'], supportsPause: false, supportsCancellation: false, supportsRetry: true, - needsOutbox: false, isAsync: false, source: 'plugin', deprecated: false, + needsOutbox: false, isAsync: false, source: 'plugin', deprecated: false, maturity: 'ga', }, async execute() { return { success: true }; }, }); diff --git a/packages/services/service-automation/src/run-history.test.ts b/packages/services/service-automation/src/run-history.test.ts index 0f608b86f7..43273be924 100644 --- a/packages/services/service-automation/src/run-history.test.ts +++ b/packages/services/service-automation/src/run-history.test.ts @@ -8,7 +8,8 @@ import { describe, it, expect } from 'vitest'; import { AutomationEngine, compactStepLogForHistory, MAX_PERSISTED_HISTORY_STEPS } from './engine.js'; -import type { StepLogEntry } from './engine.js'; +import type { StepLogEntry, NodeExecutor } from './engine.js'; +import { registerLoopNode } from './builtin/loop-node.js'; import { InMemorySuspendedRunStore } from './suspended-run-store.js'; import type { AutomationContext } from '@objectstack/spec/contracts'; @@ -256,3 +257,64 @@ describe('compactStepLogForHistory (#3234 region-aware history compaction)', () expect(out.some((s) => s.nodeId === 'outer')).toBe(true); }); }); + +// End-to-end through the real engine: a >MAX-step loop must fold its per-iteration +// body steps into the run log (#1479), then survive durable persistence + +// rehydration (#2585) with region-aware compaction (#3234) — not just the pure +// `compactStepLogForHistory` unit above. +describe('region-aware compaction — end-to-end through the engine (#3234)', () => { + const pluginCtx = () => ({ logger: silent, getService() { throw new Error('none'); } }) as never; + + it('a >MAX-step loop persists its container + recent iterations (no orphans) across a restart', async () => { + const store = new InMemorySuspendedRunStore(); + const engineA = new AutomationEngine(silent, store); + registerLoopNode(engineA, pluginCtx()); + // A trivial body node so each iteration contributes exactly one logged step. + engineA.registerNodeExecutor({ type: 'touch', async execute() { return { success: true }; } } as NodeExecutor); + + const N = 250; // > MAX_PERSISTED_HISTORY_STEPS (200) + engineA.registerFlow('big_loop', { + name: 'big_loop', label: 'Big Loop', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'loop', type: 'loop', label: 'Loop', config: { + collection: Array.from({ length: N }, (_, i) => i), + iteratorVariable: 'item', indexVariable: 'i', + body: { nodes: [{ id: 'touch', type: 'touch', label: 'Touch' }], edges: [] }, + } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'loop' }, + { id: 'e2', source: 'loop', target: 'end' }, + ], + } as never); + + const res = await engineA.execute('big_loop', { event: 'test' } as AutomationContext); + expect(res.success).toBe(true); + await flush(); // recordTerminal is fire-and-forget + + // The in-memory log keeps FULL detail: the container + all N body steps. + const live = (await engineA.listRuns('big_loop'))[0]; + expect(live.steps.filter((s) => s.nodeId === 'touch')).toHaveLength(N); + + // Simulate a restart: a fresh engine with empty in-memory logs sharing the + // same durable store → getRun serves the COMPACTED history row. + const engineB = new AutomationEngine(silent, store); + const [listed] = await engineB.listRuns('big_loop', { limit: 1 }); + const run = await engineB.getRun(listed.id); + expect(run).not.toBeNull(); + expect(run!.status).toBe('completed'); + + const steps = run!.steps; + expect(steps.length).toBeLessThanOrEqual(MAX_PERSISTED_HISTORY_STEPS); // bounded (#2585) + // The loop CONTAINER survived — pre-#3234 the plain tail-slice dropped it, + // orphaning every retained body step. + expect(steps.some((s) => s.nodeId === 'loop')).toBe(true); + // The most recent iteration survived (tail preserved). + const bodyIters = steps.filter((s) => s.nodeId === 'touch').map((s) => s.iteration!); + expect(Math.max(...bodyIters)).toBe(N - 1); + // No retained body step is orphaned from its container. + expect(hasNoOrphans(steps)).toBe(true); + }); +});