mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-14 10:39:37 +00:00
feat(automation): improve definition and trigger editing
This commit is contained in:
committed by
Carlos Valente
parent
79c21daf73
commit
b07544ab84
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user