move automation out of core
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { OverlayAction } from 'apps/companion/main/automation/application/ports/IOverlaySyncPort'
|
||||
import { IAutomationLifecycleEmitter, LifecycleCallback } from '@core/automation/infrastructure//IAutomationLifecycleEmitter'
|
||||
import { OverlaySyncService } from 'apps/companion/main/automation/application/services/OverlaySyncService'
|
||||
|
||||
class MockLifecycleEmitter implements IAutomationLifecycleEmitter {
|
||||
private callbacks: Set<LifecycleCallback> = new Set()
|
||||
onLifecycle(cb: LifecycleCallback): void {
|
||||
this.callbacks.add(cb)
|
||||
}
|
||||
offLifecycle(cb: LifecycleCallback): void {
|
||||
this.callbacks.delete(cb)
|
||||
}
|
||||
async emit(event: AutomationEvent) {
|
||||
for (const cb of Array.from(this.callbacks)) {
|
||||
// fire without awaiting to simulate async emitter
|
||||
cb(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('OverlaySyncService (unit)', () => {
|
||||
test('startAction resolves as confirmed only after action-started event is emitted', async () => {
|
||||
const emitter = new MockLifecycleEmitter()
|
||||
// create service wiring: pass emitter as dependency (constructor shape expected)
|
||||
const svc = new OverlaySyncService({
|
||||
lifecycleEmitter: emitter,
|
||||
logger: console as unknown,
|
||||
publisher: { publish: async () => {} },
|
||||
})
|
||||
|
||||
const action: OverlayAction = { id: 'add-car', label: 'Adding...' }
|
||||
|
||||
// start the action but don't emit event yet
|
||||
const promise = svc.startAction(action)
|
||||
|
||||
// wait a small tick to ensure promise hasn't resolved prematurely
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
|
||||
let resolved = false
|
||||
promise.then(() => (resolved = true))
|
||||
expect(resolved).toBe(false)
|
||||
|
||||
// now emit action-started
|
||||
await emitter.emit({ type: 'action-started', actionId: 'add-car', timestamp: Date.now() })
|
||||
|
||||
const ack = await promise
|
||||
expect(ack.status).toBe('confirmed')
|
||||
expect(ack.id).toBe('add-car')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
|
||||
import { AutomationEventPublisherPort, AutomationEvent } from '../ports/AutomationEventPublisherPort';
|
||||
import { AutomationLifecycleEmitterPort, LifecycleCallback } from '../ports/AutomationLifecycleEmitterPort';
|
||||
import { LoggerPort } from '../ports/LoggerPort';
|
||||
import type { IAsyncApplicationService } from '@core/shared/application';
|
||||
import { OverlayAction, OverlaySyncPort } from '../ports/OverlaySyncPort';
|
||||
import { ActionAck } from '../ports/IOverlaySyncPort';
|
||||
|
||||
type ConstructorArgs = {
|
||||
lifecycleEmitter: AutomationLifecycleEmitterPort
|
||||
publisher: AutomationEventPublisherPort
|
||||
logger: LoggerPort
|
||||
initialPanelWaitMs?: number
|
||||
maxPanelRetries?: number
|
||||
backoffFactor?: number
|
||||
defaultTimeoutMs?: number
|
||||
}
|
||||
|
||||
export class OverlaySyncService
|
||||
implements OverlaySyncPort, IAsyncApplicationService<OverlayAction, ActionAck>
|
||||
{
|
||||
private lifecycleEmitter: AutomationLifecycleEmitterPort
|
||||
private publisher: AutomationEventPublisherPort
|
||||
private logger: LoggerPort
|
||||
private initialPanelWaitMs: number
|
||||
private maxPanelRetries: number
|
||||
private backoffFactor: number
|
||||
private defaultTimeoutMs: number
|
||||
|
||||
constructor(args: ConstructorArgs) {
|
||||
this.lifecycleEmitter = args.lifecycleEmitter
|
||||
this.publisher = args.publisher
|
||||
this.logger = args.logger
|
||||
this.initialPanelWaitMs = args.initialPanelWaitMs ?? 500
|
||||
this.maxPanelRetries = args.maxPanelRetries ?? 3
|
||||
this.backoffFactor = args.backoffFactor ?? 2
|
||||
this.defaultTimeoutMs = args.defaultTimeoutMs ?? 5000
|
||||
}
|
||||
|
||||
async execute(action: OverlayAction): Promise<ActionAck> {
|
||||
return this.startAction(action)
|
||||
}
|
||||
|
||||
async startAction(action: OverlayAction): Promise<ActionAck> {
|
||||
const timeoutMs = action.timeoutMs ?? this.defaultTimeoutMs
|
||||
const seenEvents: AutomationEvent[] = []
|
||||
let settled = false
|
||||
|
||||
const cb: LifecycleCallback = async (ev) => {
|
||||
seenEvents.push(ev)
|
||||
if (ev.type === 'action-started' && ev.actionId === action.id) {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
cleanup()
|
||||
resolveAck({ id: action.id, status: 'confirmed' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
try {
|
||||
this.lifecycleEmitter.offLifecycle(cb)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
let resolveAck: (ack: ActionAck) => void = () => {}
|
||||
const promise = new Promise<ActionAck>((resolve) => {
|
||||
resolveAck = resolve
|
||||
try {
|
||||
this.lifecycleEmitter.onLifecycle(cb)
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(String(e))
|
||||
this.logger?.error?.('OverlaySyncService: failed to subscribe to lifecycleEmitter', error, {
|
||||
actionId: action.id,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
void this.publisher.publish({
|
||||
type: 'modal-opened',
|
||||
timestamp: Date.now(),
|
||||
payload: { actionId: action.id, label: action.label },
|
||||
actionId: action.id,
|
||||
} as AutomationEvent)
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(String(e))
|
||||
this.logger?.warn?.('OverlaySyncService: publisher.publish failed', {
|
||||
actionId: action.id,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
const timeoutPromise = new Promise<ActionAck>((res) => {
|
||||
setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
cleanup()
|
||||
this.logger?.info?.('OverlaySyncService: timeout waiting for confirmation', {
|
||||
actionId: action.id,
|
||||
timeoutMs,
|
||||
})
|
||||
const lastEvents = seenEvents.slice(-10)
|
||||
this.logger?.debug?.('OverlaySyncService: recent lifecycle events', {
|
||||
actionId: action.id,
|
||||
events: lastEvents,
|
||||
})
|
||||
res({ id: action.id, status: 'tentative', reason: 'timeout' })
|
||||
}
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
return Promise.race([promise, timeoutPromise])
|
||||
}
|
||||
|
||||
async cancelAction(actionId: string): Promise<void> {
|
||||
try {
|
||||
await this.publisher.publish({
|
||||
type: 'panel-missing',
|
||||
timestamp: Date.now(),
|
||||
actionId,
|
||||
} as AutomationEvent)
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(String(e))
|
||||
this.logger?.warn?.('OverlaySyncService: cancelAction publish failed', {
|
||||
actionId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user