Files
sencho/frontend/src/lib/__tests__/scheduledActions.test.ts
T
Anso 10fb93dcb1 feat(scheduler): schedule container restart, stop, and start (#1526)
* feat(scheduler): schedule container restart, stop, and start

Add container as a scheduled-task target type so operators can automate lifecycle actions against standalone containers by node and name, with matching UI pickers, validation, execution on local and remote nodes, and tests.

* fix(scheduler): stack service matching and container picker hygiene

Backfill Service on smartFallback containers so per-service stack restarts work when container_name is set. Match services by compose label and container name in stack routes and scheduled restarts. Exclude Sencho from GET /api/containers lists. Hide the Restart Stack service picker when a stack has only one service.

* test(scheduler): scope service checkbox assertion to Services block

The create dialog also has a Delete after run checkbox. Count checkboxes only inside the Services section so CI does not include unrelated form controls.

* fix(scheduler): narrow closest() result to HTMLElement in schedule test

The service-checkbox assertion passed an Element from closest() into
within(), which requires an HTMLElement, failing tsc -b in the frontend
build and Docker build stages. Use the closest<HTMLElement>() type
argument so the value type-checks without an unsafe cast.

* fix(scheduler): hide Sencho container on remote node picker lists

Remote container lists are proxied from peer Sencho instances, so id-only self filtering missed peers on older builds. Await SelfIdentity init, match ImageID, and drop official saelix/sencho images. Apply the same heuristic in the scheduled-operations UI and when the hub fetches remote containers for scheduled runs.

* test(monitor): add missing DatabaseService mocks for scan history cleanup

* test(scheduler): add missing markStaleScansAsFailed mock

SchedulerService.tick() calls db.markStaleScansAsFailed() to sweep stale
vulnerability scans. The scheduler-service test was missing this method in
its DatabaseService mock, causing TypeError failures during test initialization.

Added mockMarkStaleScansAsFailed to hoisted mocks and DatabaseService mock
object, returning safe default of 0 scans marked as failed.

* test(compose): add missing FileSystemService mocks for getStackContent/getEnvContent

* test(containers-route): mock SelfIdentityService to prevent initialize() crash

The excludeSelfContainers() helper calls SelfIdentityService.initialize(), which tries to access DockerController. Without a proper SelfIdentityService mock, the initialize() call fails silently, causing a 500 error on GET /api/containers.

Added SelfIdentityService mock with initialize(), isOwnContainer(), and isOwnImage() methods to prevent the crash.
2026-07-02 22:31:29 -04:00

247 lines
10 KiB
TypeScript

/**
* Locks the frontend scheduled-action registry: the task-to-definition
* resolution (including the update-fleet alias), and the parity between the
* registry's backend actions and the wire action union.
*/
import { describe, it, expect } from 'vitest';
import {
SCHEDULED_ACTIONS,
SCHEDULED_ACTION_CATEGORIES,
DEFAULT_SCHEDULED_ACTION_ID,
getActionById,
resolveTaskAction,
scheduleTargetDescriptor,
stripComposeExt,
RISK_LABEL,
RISK_TONE,
RISK_BADGE_CLASSES,
RISK_DOT_CLASSES,
type BackendAction,
type ScheduledActionCategory,
type ScheduledActionRiskLevel,
} from '../scheduledActions';
import type { ScheduledTask } from '@/types/scheduling';
type TargetTask = Pick<ScheduledTask, 'action' | 'target_type' | 'target_id' | 'name'>;
const BACKEND_ACTIONS: BackendAction[] = [
'restart', 'snapshot', 'prune', 'update', 'scan',
'auto_backup', 'auto_stop', 'auto_down', 'auto_start',
];
const CATEGORY_KEYS: ScheduledActionCategory[] = SCHEDULED_ACTION_CATEGORIES.map(c => c.key);
describe('scheduledActions registry', () => {
it('every backendAction is a known wire action', () => {
for (const def of SCHEDULED_ACTIONS) {
expect(BACKEND_ACTIONS).toContain(def.backendAction);
}
});
it('covers every wire action with at least one entry', () => {
const covered = new Set(SCHEDULED_ACTIONS.map(d => d.backendAction));
expect([...covered].sort()).toEqual([...BACKEND_ACTIONS].sort());
});
it('every entry uses a defined category lane', () => {
for (const def of SCHEDULED_ACTIONS) {
expect(CATEGORY_KEYS).toContain(def.category);
}
});
it('marks scan and prune as local-node actions', () => {
expect(getActionById('scan')).toMatchObject({ requiresNode: true, nodeScope: 'local' });
expect(getActionById('prune')).toMatchObject({ requiresNode: true, nodeScope: 'local' });
});
it('getActionById resolves a known id and returns undefined otherwise', () => {
expect(getActionById('restart')?.label).toBe('Restart Stack');
expect(getActionById('nope')).toBeUndefined();
});
describe('resolveTaskAction', () => {
it('maps update + fleet to the update-fleet UI entry', () => {
const def = resolveTaskAction({ action: 'update', target_type: 'fleet' });
expect(def?.id).toBe('update-fleet');
expect(def?.backendAction).toBe('update');
});
it('maps update + stack to the direct update entry', () => {
const def = resolveTaskAction({ action: 'update', target_type: 'stack' });
expect(def?.id).toBe('update');
});
it('maps container target types to container UI entries', () => {
expect(resolveTaskAction({ action: 'restart', target_type: 'container' })?.id).toBe('container-restart');
expect(resolveTaskAction({ action: 'auto_stop', target_type: 'container' })?.id).toBe('container-stop');
expect(resolveTaskAction({ action: 'auto_start', target_type: 'container' })?.id).toBe('container-start');
});
it('maps a non-aliased action to its direct entry', () => {
expect(resolveTaskAction({ action: 'restart', target_type: 'stack' })?.id).toBe('restart');
expect(resolveTaskAction({ action: 'snapshot', target_type: 'fleet' })?.id).toBe('snapshot');
});
it('returns undefined for an unknown action', () => {
expect(resolveTaskAction({ action: 'bogus' as BackendAction, target_type: 'system' })).toBeUndefined();
});
});
it('exports DEFAULT_SCHEDULED_ACTION_ID as restart', () => {
expect(DEFAULT_SCHEDULED_ACTION_ID).toBe('restart');
});
it('orders actions by category group', () => {
const ids = SCHEDULED_ACTIONS.map(a => a.id);
// Verify the exact order: lifecycle first, then updates, security, maintenance, backups.
expect(ids).toEqual([
'auto_backup', 'auto_start', 'restart', 'auto_stop', 'auto_down',
'container-restart', 'container-stop', 'container-start',
'update', 'update-fleet',
'scan',
'prune',
'snapshot',
]);
});
it('preserves the update-fleet alias after resorting', () => {
const fleetUpdate = SCHEDULED_ACTIONS.find(a => a.id === 'update-fleet');
expect(fleetUpdate).toBeDefined();
expect(fleetUpdate!.backendAction).toBe('update');
expect(fleetUpdate!.targetType).toBe('fleet');
});
describe('helperText', () => {
const expected: Record<string, string> = {
'auto_backup': 'Backs up compose and env files only. This does not back up application volumes.',
'auto_start': 'Creates containers if they do not exist, or starts existing stopped containers.',
'restart': 'Restarts containers in place. Running services are stopped and started again on the same configuration.',
'auto_stop': 'Stops containers but keeps them in place for a faster start later.',
'auto_down': 'Runs compose down. Containers are removed, but compose files remain on disk.',
'container-restart': 'Restarts a single container by name on the selected node. Targets the container directly, not through compose.',
'container-stop': 'Stops a single container by name. The container remains on disk for a faster start later.',
'container-start': 'Starts a stopped container by name on the selected node.',
'update': "Checks this stack's images and recreates the stack only when newer images are available.",
'update-fleet': 'Checks every stack on the selected node and updates stacks with newer images.',
'scan': 'Runs Trivy against images on the selected local node and records the findings.',
'prune': 'Removes unused Docker resources on the selected node. Be careful when pruning volumes.',
'snapshot': 'Creates a versioned snapshot of compose and env files across the fleet.',
};
for (const [id, text] of Object.entries(expected)) {
it(`${id} helper text matches the specified wording`, () => {
expect(getActionById(id)?.helperText).toBe(text);
});
}
});
describe('riskLevel', () => {
const expected: Record<string, ScheduledActionRiskLevel> = {
'auto_backup': 'safe',
'auto_start': 'runtime-change',
'restart': 'interruptive',
'auto_stop': 'interruptive',
'auto_down': 'removes-containers',
'container-restart': 'interruptive',
'container-stop': 'interruptive',
'container-start': 'runtime-change',
'update': 'runtime-change',
'update-fleet': 'runtime-change',
'scan': 'read-only',
'prune': 'destructive',
'snapshot': 'safe',
};
for (const [id, level] of Object.entries(expected)) {
it(`${id} risk level is ${level}`, () => {
expect(getActionById(id)?.riskLevel).toBe(level);
});
}
it('every action has a RISK_LABEL entry', () => {
for (const def of SCHEDULED_ACTIONS) {
expect(RISK_LABEL[def.riskLevel]).toBeTruthy();
}
});
});
describe('risk metadata maps', () => {
it('RISK_TONE maps every action risk level', () => {
for (const def of SCHEDULED_ACTIONS) {
expect(RISK_TONE[def.riskLevel]).toBeTruthy();
}
});
it('RISK_BADGE_CLASSES maps every action risk level', () => {
for (const def of SCHEDULED_ACTIONS) {
expect(RISK_BADGE_CLASSES[def.riskLevel]).toBeTruthy();
}
});
it('RISK_DOT_CLASSES maps every action risk level', () => {
for (const def of SCHEDULED_ACTIONS) {
expect(RISK_DOT_CLASSES[def.riskLevel]).toBeTruthy();
}
});
});
it('resolveTaskAction for update+fleet returns correct metadata', () => {
const def = resolveTaskAction({ action: 'update', target_type: 'fleet' });
expect(def?.id).toBe('update-fleet');
expect(def?.riskLevel).toBe('runtime-change');
expect(def?.helperText).toBe('Checks every stack on the selected node and updates stacks with newer images.');
});
describe('stripComposeExt', () => {
it('drops a trailing .yml or .yaml and leaves other names alone', () => {
expect(stripComposeExt('web')).toBe('web');
expect(stripComposeExt('web.yml')).toBe('web');
expect(stripComposeExt('web.yaml')).toBe('web');
expect(stripComposeExt('')).toBe('');
expect(stripComposeExt('my.app')).toBe('my.app');
});
});
describe('scheduleTargetDescriptor', () => {
it('shows the stack name (without compose extension) for stack actions', () => {
const task: TargetTask = { action: 'restart', target_type: 'stack', target_id: 'web.yml', name: 'Nightly restart' };
expect(scheduleTargetDescriptor(task, 'hub')).toBe('web');
});
it('falls back to the task name when a stack target_id is missing', () => {
const task: TargetTask = { action: 'restart', target_type: 'stack', target_id: null, name: 'api.yaml' };
expect(scheduleTargetDescriptor(task)).toBe('api');
});
it('shows the node for a fleet auto-update, or a generic label without one', () => {
const task: TargetTask = { action: 'update', target_type: 'fleet', target_id: null, name: 'Fleet update' };
expect(scheduleTargetDescriptor(task, 'edge-1')).toBe('All stacks · edge-1');
expect(scheduleTargetDescriptor(task)).toBe('All stacks');
});
it('shows Entire fleet for a fleet snapshot regardless of node', () => {
const task: TargetTask = { action: 'snapshot', target_type: 'fleet', target_id: null, name: 'Nightly Snapshot' };
expect(scheduleTargetDescriptor(task, 'edge-1')).toBe('Entire fleet');
expect(scheduleTargetDescriptor(task)).toBe('Entire fleet');
});
it('shows the container name for container actions', () => {
const task: TargetTask = {
action: 'restart',
target_type: 'container',
target_id: 'watchtower',
name: 'Daily watchtower restart',
};
expect(scheduleTargetDescriptor(task, 'hub')).toBe('watchtower');
});
it('shows the node for system actions (prune / scan), with a fallback', () => {
const scan: TargetTask = { action: 'scan', target_type: 'system', target_id: null, name: 'Vul Scan' };
const prune: TargetTask = { action: 'prune', target_type: 'system', target_id: null, name: 'Nightly Prune' };
expect(scheduleTargetDescriptor(scan, 'hub')).toBe('hub');
expect(scheduleTargetDescriptor(prune, 'edge-1')).toBe('edge-1');
expect(scheduleTargetDescriptor(scan)).toBe('Selected node');
});
});
});