refactor(automation): trim the branch to reduce risk

Adoption is low, and the app must not be put at risk fixing that. A measured
look at the diff showed the risk was concentrated in exactly two places: the
timer's hot path and a new file-writing route. Everything that actually makes
automations discoverable — recipes, a panel that explains itself, one-step
creation, Test buttons that report — is client-only settings-panel code with
zero server churn. This cuts the two pieces that weren't, and simplifies two
more that only existed to serve them.

Removed entirely: templates. Newest, riskiest, and the least connected to the
adoption problem — it helps someone who already uses automations share them,
not someone who has never tried the feature. It was also the source of two of
the bugs found in review.

Removed: the websocket broadcast behind "last fired". reportFired ran inside
triggerAutomations, called from onClock every second and from onUpdate. A new
protocol message pushed to every connected client, including stage displays,
up to once a second per automation, is real new traffic on the busiest code
path in the app for a feature still trying to prove it's worth using. The log
line stays: it's additive, throttled, nowhere near the hot path once written,
and answers the same question — did this run — without a new message.

Trimmed: the demo goes from two automations tied together by necessity — a
danger-time message and a second automation whose only job was undoing the
first on finish — to one, attached by an event-level trigger instead of a
global one. Same two discovery wins, an automation visibly fires on Play and
per-event triggers are found by opening an event, with no message mutation
and nothing to keep in sync. That pairing was also the first thing review
found broken.

Trimmed: the delete dialog no longer deletes blocking triggers and restores
them if the automation still won't delete. That rollback was the other
multi-request destructive sequence review found a bug in. It now confirms,
names what's blocking, and says to remove global triggers from the Global
Triggers list first — a single always-safe request the user already has.

What stays: one-step creation (lifecycles picked on the automation form), the
recipe library, and the panel legibility work — none of it touches the server
or the runtime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LpbLJVVT26tzWkduck1M9H
This commit is contained in:
Claude
2026-08-08 21:12:26 +00:00
parent cdde054b14
commit ed39f86a81
21 changed files with 40 additions and 725 deletions
+2 -62
View File
@@ -2,23 +2,12 @@ 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.
* Covers the automation delete flow: the server refuses to delete an automation that a
* trigger still points at, and clears once the reference is removed.
*/
test.describe('automations', () => {
/**
* Everything created is torn down in afterEach rather than at the end of the test body:
* a mid-test failure would otherwise leak an automation into the project, and CI retries twice.
*
* The template is tracked by the name the server actually used, since it resolves collisions.
*/
let createdTemplate: string | null = null;
let createdTriggers: string[] = [];
let createdAutomations: string[] = [];
@@ -31,63 +20,14 @@ test.describe('automations', () => {
for (const id of createdAutomations) {
await request.delete(`${automationsURL}/automation/${id}`);
}
if (createdTemplate !== null) {
await request.delete(`${dbURL}/${createdTemplate}`);
}
} catch {
// cleanup is best effort, it must not turn a passing test red
} finally {
createdTriggers = [];
createdAutomations = [];
createdTemplate = null;
}
});
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();
createdAutomations.push(automation.id);
// 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);
createdTriggers.push((await createTrigger.json()).id);
// 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);
createdTemplate = (await makeTemplate.json()).filename;
expect(createdTemplate).toBeTruthy();
// 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: createdTemplate } })).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([]);
});
test('refuses to delete an automation that a trigger still points at', async ({ request }) => {
const automation = await (
await request.post(`${automationsURL}/automation`, {