feat(automation): improve definition and trigger editing

This commit is contained in:
Carlos Valente
2026-09-13 09:51:47 +02:00
committed by Carlos Valente
parent 79c21daf73
commit b07544ab84
33 changed files with 865 additions and 370 deletions
@@ -51,4 +51,34 @@ describe('automation controllers', () => {
);
});
it('persists definitions using the not_contains filter operator', async () => {
const request = {
body: {
...requestBody,
filters: [{ field: 'eventNow.title', operator: 'not_contains', value: 'break' }],
},
} as Request;
await postAutomation(request, makeResponse());
expect(automationDao.addAutomation).toHaveBeenCalledWith(
expect.objectContaining({ filters: [expect.objectContaining({ operator: 'not_contains' })] }),
);
});
it('rejects trigger bindings when editing a definition', async () => {
const request = {
body: { ...requestBody, triggers: [{ trigger: 'onStart', automationId: 'other-definition' }] },
params: { id: 'automation-id' },
} as unknown as Request;
const response = makeResponse();
await editAutomation(request, response);
expect(automationDao.editAutomation).not.toHaveBeenCalled();
expect(response.status).toHaveBeenCalledWith(400);
expect(response.send).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Automation definitions cannot include triggers' }),
);
});
});
@@ -146,7 +146,7 @@ describe('editAutomation()', () => {
title: 'test-osc',
filterRule: 'all',
filters: [],
outputs: [],
outputs: [makeOSCAction(), makeHTTPAction()],
});
await addAutomation({
title: 'test-http',
@@ -164,7 +164,7 @@ describe('editAutomation()', () => {
title: 'test-osc',
filterRule: 'all',
filters: expect.any(Array),
outputs: expect.any(Array),
outputs: [makeOSCAction(), makeHTTPAction()],
});
const editedOSC = await editAutomation(firstAutomation.id, {
@@ -179,9 +179,34 @@ describe('editAutomation()', () => {
title: 'edited-title',
filterRule: 'any',
filters: expect.any(Array),
outputs: expect.any(Array),
outputs: [],
});
});
it('replaces all outputs when an automation definition is edited', async () => {
const editedWithOneOutput = await editAutomation(firstAutomation.id, {
title: 'edited-title',
filterRule: 'any',
filters: [],
outputs: [makeHTTPAction()],
});
expect(editedWithOneOutput.outputs).toEqual([makeHTTPAction()]);
});
it('preserves global triggers when a definition is edited', async () => {
await addTrigger({ title: 'On Start', trigger: TimerLifeCycle.onStart, automationId: firstAutomation.id });
await editAutomation(firstAutomation.id, {
title: 'edited-title',
filterRule: 'all',
filters: [],
outputs: [],
});
expect(getAutomationTriggers()).toEqual([
expect.objectContaining({ automationId: firstAutomation.id, trigger: TimerLifeCycle.onStart }),
]);
});
});
describe('deleteAutomation()', () => {
@@ -225,4 +250,35 @@ describe('deleteAutomation()', () => {
const removed = getAutomations();
expect(Object.keys(removed).length).toEqual(0);
});
it('refuses an automation attached to a global trigger', async () => {
const automationId = Object.keys(getAutomations())[0];
await addTrigger({ title: 'On Start', trigger: TimerLifeCycle.onStart, automationId });
await expect(deleteAutomation({}, automationId)).rejects.toThrow(/used in trigger/);
expect(getAutomationTriggers()).toHaveLength(1);
expect(getAutomations()[automationId]).toBeDefined();
});
it('refuses an automation attached to an event', async () => {
const automationId = Object.keys(getAutomations())[0];
const projectRundowns: ProjectRundowns = {
'rundown-1': {
id: 'rundown-1',
title: 'Rundown 1',
order: ['1'],
flatOrder: ['1'],
entries: {
'1': makeOntimeEvent({
id: '1',
triggers: [{ id: 'trigger-1', title: 'Trigger 1', trigger: TimerLifeCycle.onClock, automationId }],
}),
},
revision: 1,
},
};
await expect(deleteAutomation(projectRundowns, automationId)).rejects.toThrow(/used in rundown/);
expect(getAutomations()[automationId]).toBeDefined();
});
});
@@ -1,7 +1,17 @@
import { ProjectRundowns, TimerLifeCycle } from 'ontime-types';
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
import { isAutomationUsed, parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
import { isAutomationUsed, isHostname, parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
describe('isHostname()', () => {
it.each(['localhost', 'qlab', 'osc.example.com', 'osc-target.example'])('accepts %s', (hostname) => {
expect(isHostname(hostname)).toBe(true);
});
it.each(['not a host', '-osc.example', 'osc-.example', 'osc..example', 'osc.example.'])('rejects %s', (hostname) => {
expect(isHostname(hostname)).toBe(false);
});
});
describe('parseTemplateNested()', () => {
it('parses string with a single-level variable name', () => {
@@ -1,4 +1,12 @@
import { parseOutput } from '../automation.validation.js';
import { parseAutomation, parseOutput } from '../automation.validation.js';
describe('parseAutomation', () => {
it('rejects trigger bindings from definition payloads', () => {
expect(() =>
parseAutomation({ title: 'Definition', filterRule: 'all', filters: [], outputs: [], triggers: [] }),
).toThrow('Automation definitions cannot include triggers');
});
});
describe('parseOutput', () => {
describe('handles OSC outputs', () => {
@@ -0,0 +1,57 @@
import { runtimeStorePlaceholder } from 'ontime-types';
const { send } = vi.hoisted(() => ({ send: vi.fn() }));
vi.mock('node:dgram', () => ({
createSocket: vi.fn(() => ({ send })),
}));
import { emitOSC } from '../clients/osc.client.js';
describe('emitOSC()', () => {
beforeEach(() => {
send.mockClear();
});
it('resolves templates in the target host before sending', () => {
emitOSC(
{
type: 'osc',
targetIP: '{{eventNow.custom.oscTarget}}',
targetPort: 53000,
address: '/cue/start',
args: '',
},
{
...runtimeStorePlaceholder,
eventNow: {
id: 'current-event',
type: 'event',
cue: '1',
title: 'Opening',
note: '',
timeStart: 0,
timeEnd: 0,
duration: 0,
timerType: 'count-down',
colour: '',
delay: 0,
isPublic: true,
skip: false,
endAction: 'none',
revision: 0,
custom: { oscTarget: '192.0.2.10' },
},
},
);
expect(send).toHaveBeenCalledWith(
expect.anything(),
0,
expect.any(Number),
53000,
'192.0.2.10',
expect.any(Function),
);
});
});
@@ -1,4 +1,5 @@
import {
AutomationFilter,
EntryId,
FilterRule,
MaybeNumber,
@@ -11,10 +12,16 @@ import {
import { getPropertyFromPath, millisToString, removeLeadingZero, splitWhitespace } from 'ontime-utils';
import type { OscArgInput, OscArgOrArrayInput } from 'osc-min';
type FilterOperator = 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains';
type FilterOperator = AutomationFilter['operator'];
const hostnameRegex = /^(?=.{1,253}$)[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?(?:\.[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?)*$/i;
export function isHostname(value: string): boolean {
return hostnameRegex.test(value);
}
export function isFilterOperator(value: string): value is FilterOperator {
return ['equals', 'not_equals', 'greater_than', 'less_than', 'contains'].includes(value);
return ['equals', 'not_equals', 'greater_than', 'less_than', 'contains', 'not_contains'].includes(value);
}
export function isFilterRule(value: string): value is FilterRule {
@@ -14,7 +14,7 @@ import {
import * as assert from '../../utils/assert.js';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
import { isFilterOperator, isFilterRule, isOntimeActionAction } from './automation.utils.js';
import { isFilterOperator, isFilterRule, isHostname, isOntimeActionAction } from './automation.utils.js';
export const validateAutomationSettings = [
body('enabledAutomations').isBoolean(),
@@ -62,6 +62,10 @@ export function parseAutomation(maybeAutomation: unknown): AutomationDTO {
assert.isObject(maybeAutomation);
assert.hasKeys(maybeAutomation, ['title', 'filterRule', 'filters', 'outputs']);
if ('triggers' in maybeAutomation) {
throw new Error('Automation definitions cannot include triggers');
}
const { title, filterRule, filters, outputs } = maybeAutomation;
assert.isString(title);
assert.isString(filterRule);
@@ -128,11 +132,8 @@ function parseOSCOutput(maybeOSCOutput: object): OSCOutput {
assert.isString(maybeOSCOutput.args);
const targetIP = maybeOSCOutput.targetIP.trim();
const target = replaceAutomationTemplates(targetIP, 'template.local');
const isHostname = /^(?=.{1,253}$)[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?(?:\.[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?)*$/i.test(
target,
);
if (isIP(target) !== 4 && !isHostname) {
const target = replaceTemplatesForValidation(targetIP, 'template.local');
if (isIP(target) !== 4 && !isHostname(target)) {
throw new Error('Invalid OSC target');
}
if (
@@ -171,7 +172,8 @@ function parseHTTPOutput(maybeHTTPOutput: object): HTTPOutput {
};
}
function replaceAutomationTemplates(value: string, replacement: string): string {
/** Replaces runtime values so their surrounding host or URL syntax can be validated before execution. */
function replaceTemplatesForValidation(value: string, replacement: string): string {
return value.replace(/{{.*?}}/g, replacement);
}
@@ -179,7 +181,7 @@ function replaceHTTPTemplatesForValidation(value: string): string {
if (/^{{.*?}}$/.test(value)) {
return 'https://template.local';
}
return replaceAutomationTemplates(value, 'template');
return replaceTemplatesForValidation(value, 'template');
}
function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
@@ -14,7 +14,8 @@ const udpClient = dgram.createSocket('udp4');
*/
export function emitOSC(output: OSCOutput, store: DeepReadonly<RuntimeStore>) {
const message = preparePayload(output, store);
emit(output.targetIP, output.targetPort, message);
const targetIP = parseTemplateNested(output.targetIP, store);
emit(targetIP, output.targetPort, message);
}
/** Parses the state and prepares payload to be emitted */