mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-20 22:49:18 +00:00
feat(project): save part of a project as a template
Automations were already portable, contrary to the original framing: project download carries them and the partial load already had an "Automation Settings" toggle. What was missing is the other half of that loop. To share a set of automations you had to hand over an entire show. Save as template is the inverse of partial load: it writes a new project file holding only the sections you pick. Automations, custom fields, URL presets and view settings all become templates, and a template is an ordinary project file, so it downloads, uploads and partial loads with no new format and no new concepts. createProjectWithPatch already builds exactly the right object, but it routes through loadProject and would switch the running project. Saving a template must not pull an operator out of the show they are running, so this writes the file without loading it, the way duplicateProjectFile already does. The automations panel gets the loop as a Share menu: save these automations as a template, or go load one. The form says plainly that applying an automation template replaces rather than merges, since triggers and automations are coupled and safeMerge swaps the whole block, and that a template without rundowns does not carry the events that point at its automations. Adds the first e2e coverage automations have had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpbLJVVT26tzWkduck1M9H
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const baseURL = 'http://localhost:4001';
|
||||
const automationsURL = `${baseURL}/data/automations`;
|
||||
const dbURL = `${baseURL}/data/db`;
|
||||
|
||||
const templateName = 'e2e-automations-template';
|
||||
|
||||
/**
|
||||
* Covers the loop that makes automations shareable:
|
||||
* create one, clone only the automations into a template project, and check that
|
||||
* the template carries the automation and its trigger while leaving the rundown behind.
|
||||
*/
|
||||
test.describe('automations', () => {
|
||||
test.afterEach(async ({ request }) => {
|
||||
try {
|
||||
await request.delete(`${dbURL}/${templateName}.json`);
|
||||
} catch {
|
||||
/** nothing to do here */
|
||||
}
|
||||
});
|
||||
|
||||
test('an automation and its trigger survive a round trip through a template project', async ({ request }) => {
|
||||
// 1. create an automation
|
||||
const createAutomation = await request.post(`${automationsURL}/automation`, {
|
||||
data: {
|
||||
title: 'e2e automation',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [{ type: 'ontime', action: 'aux1-start' }],
|
||||
},
|
||||
});
|
||||
expect(createAutomation.status()).toBe(201);
|
||||
const automation = await createAutomation.json();
|
||||
|
||||
// 2. bind it to a lifecycle
|
||||
const createTrigger = await request.post(`${automationsURL}/trigger`, {
|
||||
data: { title: 'e2e trigger', trigger: 'onStart', automationId: automation.id },
|
||||
});
|
||||
expect(createTrigger.status()).toBe(201);
|
||||
const trigger = await createTrigger.json();
|
||||
|
||||
// 3. save the automations as a template, without touching the loaded project
|
||||
const projectList = await (await request.get(`${dbURL}/all`)).json();
|
||||
const currentProject = projectList.lastLoadedProject;
|
||||
|
||||
const makeTemplate = await request.post(`${dbURL}/${currentProject}/partial-duplicate`, {
|
||||
data: { newFilename: templateName, sections: ['automation'] },
|
||||
});
|
||||
expect(makeTemplate.status()).toBe(201);
|
||||
|
||||
// the running project is untouched
|
||||
const afterTemplate = await (await request.get(`${dbURL}/all`)).json();
|
||||
expect(afterTemplate.lastLoadedProject).toBe(currentProject);
|
||||
|
||||
// 4. the template carries the automation and its trigger, and nothing else
|
||||
const template = await (await request.post(`${dbURL}/download`, { data: { filename: templateName } })).json();
|
||||
expect(Object.values(template.automation.automations)).toContainEqual(
|
||||
expect.objectContaining({ title: 'e2e automation' }),
|
||||
);
|
||||
expect(template.automation.triggers).toContainEqual(expect.objectContaining({ title: 'e2e trigger' }));
|
||||
expect(template.urlPresets).toEqual([]);
|
||||
|
||||
// 5. clean up: the trigger has to go first, the server refuses to delete a referenced automation
|
||||
expect((await request.delete(`${automationsURL}/trigger/${trigger.id}`)).status()).toBe(204);
|
||||
expect((await request.delete(`${automationsURL}/automation/${automation.id}`)).status()).toBe(204);
|
||||
});
|
||||
|
||||
test('refuses to delete an automation that a trigger still points at', async ({ request }) => {
|
||||
const automation = await (
|
||||
await request.post(`${automationsURL}/automation`, {
|
||||
data: {
|
||||
title: 'e2e referenced automation',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [{ type: 'ontime', action: 'aux1-stop' }],
|
||||
},
|
||||
})
|
||||
).json();
|
||||
|
||||
const trigger = await (
|
||||
await request.post(`${automationsURL}/trigger`, {
|
||||
data: { title: 'e2e blocking trigger', trigger: 'onFinish', automationId: automation.id },
|
||||
})
|
||||
).json();
|
||||
|
||||
const refused = await request.delete(`${automationsURL}/automation/${automation.id}`);
|
||||
expect(refused.status()).toBe(400);
|
||||
expect((await refused.json()).message).toContain('e2e blocking trigger');
|
||||
|
||||
await request.delete(`${automationsURL}/trigger/${trigger.id}`);
|
||||
expect((await request.delete(`${automationsURL}/automation/${automation.id}`)).status()).toBe(204);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user