Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/service-automation-test-hardening.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ describe('AutomationEngine connector registry', () => {
inputSchema: { message: { type: 'string' } },
},
],
} as Connector,
} as unknown as Connector,
{ async echo() { return {}; } },
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | undefined)?.lastSeen).toBe('n:Z');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ async function bootWithConnectors(

function auditWarnings(warnSpy: ReturnType<typeof vi.spyOn>): 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)', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown>) => ({
ok: true,
Expand Down Expand Up @@ -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); },
};
Expand Down Expand Up @@ -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 }) },
};
};
Expand Down Expand Up @@ -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); },
};
Expand Down
5 changes: 3 additions & 2 deletions packages/services/service-automation/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
},
Expand Down Expand Up @@ -2242,6 +2242,7 @@ describe('Action Descriptor Registry (ADR-0018)', () => {
isAsync: false,
source: 'plugin',
deprecated: false,
maturity: 'ga',
},
async execute() { return { success: true }; },
});
Expand All @@ -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 }; },
});
Expand Down
64 changes: 63 additions & 1 deletion packages/services/service-automation/src/run-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
});
});