Two silent-death defects in one class: 1. Seven emitters absent from DEFAULT events, so the send() gate (config.events[canonical] !== true) dropped them on every install: ssl-cert-expiry, dns-propagation, drift-detected, dependency-restart-complete/-failed, recipeRemoved, workflow. All now default ON; dependency-restart spellings fold onto one canonical toggle; recipeRemoved aliases to recipe-removed in both the manager and route alias maps. 2. Nine call sites used a legacy 4-arg send(event, title, message, type) against the 3-arg signature: the message string landed in the type slot (Discord embed color fell back to info-blue, history.type wrong) and providers received the TITLE as the body - deploy-failure notifications carried no error text at all. Fixed at source (9 sites) plus a type-guarded shim in send() for external legacy callers. Also: explicit data.title now flows to ntfy Title header, email subject, Discord embed title, and history; settings UI gains 9 event toggles (separate Backup Complete/Failed) with defaults-on semantics. Tests: +24 (new DC-094 suite: defaults, gate pass-through, alias folding send-time and load-time, stored-config inheritance, shim body/title/ color/subject/history, type-guard, 3-arg no-regression); 4 assertions in bundled-workflows-health-check updated from the old 4-arg mock contract to the canonical shape (same behavior asserted). Full suite 116 suites / 2706 tests green. Judge: GLM-5.3 cold read via delegate_task (deleg_8a7cedd0), grade A, zero blockers; 2 polish items (Backups toggle conflation, shim type-guard) folded into this commit. Verdict URN: urn:ump:uyipjjwdjqjy3alvceqxvlucrymd7hnsben5udpp2bqycoh3l2la
401 lines
15 KiB
JavaScript
401 lines
15 KiB
JavaScript
/**
|
|
* Regression tests for WorkflowEngine.healthCheckService (DC-042 followup).
|
|
*
|
|
* Bug: bundled-workflows.js:310 called `servicesStateManager.getState()` —
|
|
* a method that doesn't exist on StateManager. Combined with a missing
|
|
* `await`, this returned a Promise instead of an array, which then short-
|
|
* circuited via `|| []` to an empty array. The result: every health-check-
|
|
* on-interval workflow ran successfully with 0 services checked, while
|
|
* the workflow engine still reported "Action health-check failed:
|
|
* servicesStateManager.getState is not a function" on the dashboard.
|
|
*
|
|
* Fix: call `await servicesStateManager.read()` with a .catch fallback to
|
|
* an empty array so a corrupt/missing state file doesn't break the
|
|
* workflow.
|
|
*/
|
|
|
|
const { WorkflowEngine } = require('../src/recipes/bundled-workflows');
|
|
|
|
function makeEngine(opts = {}) {
|
|
const ctx = {
|
|
servicesStateManager: opts.servicesStateManager || {
|
|
read: jest.fn().mockResolvedValue([]),
|
|
},
|
|
docker: opts.docker !== undefined ? opts.docker : {
|
|
client: {
|
|
getContainer: jest.fn(),
|
|
},
|
|
},
|
|
};
|
|
const engine = new WorkflowEngine(ctx);
|
|
// The constructor calls startScheduledWorkflows() which sets setInterval jobs.
|
|
// Those prevent Jest from exiting cleanly. Clear them after construction.
|
|
// We only care about healthCheckService behavior here, not scheduling.
|
|
if (engine.scheduledJobs) {
|
|
for (const job of engine.scheduledJobs.values()) {
|
|
clearInterval(job);
|
|
}
|
|
engine.scheduledJobs.clear();
|
|
}
|
|
return engine;
|
|
}
|
|
|
|
describe('WorkflowEngine.healthCheckService — DC-042 followup (getState bug)', () => {
|
|
test('uses .read() not the non-existent .getState() — does not throw', async () => {
|
|
const readMock = jest.fn().mockResolvedValue([]);
|
|
const engine = makeEngine({
|
|
servicesStateManager: { read: readMock },
|
|
docker: undefined, // no docker — exercises the falsy branch
|
|
});
|
|
|
|
// The original bug: this throws `servicesStateManager.getState is not a function`
|
|
const result = await engine.healthCheckService('{{serviceId}}');
|
|
|
|
expect(readMock).toHaveBeenCalledTimes(1);
|
|
expect(result).toEqual({ checked: 0, healthy: 0, results: [], failing: [] });
|
|
});
|
|
|
|
test('returns checked/healthy counts from read() output (all healthy)', async () => {
|
|
const docker = {
|
|
client: {
|
|
getContainer: jest.fn((id) => ({
|
|
inspect: jest.fn().mockResolvedValue({
|
|
State: { Running: true, Health: { Status: 'healthy' } },
|
|
}),
|
|
})),
|
|
},
|
|
};
|
|
const engine = makeEngine({
|
|
servicesStateManager: {
|
|
read: jest.fn().mockResolvedValue([
|
|
{ id: 'svc-1', containerId: 'c1' },
|
|
{ id: 'svc-2', containerId: 'c2' },
|
|
{ id: 'svc-3' }, // no containerId, should be skipped
|
|
]),
|
|
},
|
|
docker,
|
|
});
|
|
|
|
const result = await engine.healthCheckService('{{serviceId}}');
|
|
|
|
expect(result.checked).toBe(2); // svc-3 skipped (no containerId)
|
|
expect(result.healthy).toBe(2); // both containers healthy
|
|
expect(result.results).toHaveLength(2);
|
|
expect(result.results[0]).toMatchObject({ service: 'svc-1', healthy: true });
|
|
expect(result.results[1]).toMatchObject({ service: 'svc-2', healthy: true });
|
|
expect(result.failing).toEqual([]);
|
|
});
|
|
|
|
test('throws when any service is unhealthy — surfaces failing service IDs', async () => {
|
|
const docker = {
|
|
client: {
|
|
getContainer: jest.fn((id) => ({
|
|
inspect: jest.fn().mockResolvedValue({
|
|
State: { Running: id !== 'c2', Health: { Status: id === 'c2' ? 'unhealthy' : 'healthy' } },
|
|
}),
|
|
})),
|
|
},
|
|
};
|
|
const engine = makeEngine({
|
|
servicesStateManager: {
|
|
read: jest.fn().mockResolvedValue([
|
|
{ id: 'svc-1', containerId: 'c1' },
|
|
{ id: 'svc-2', containerId: 'c2' },
|
|
]),
|
|
},
|
|
docker,
|
|
});
|
|
|
|
await expect(engine.healthCheckService('{{serviceId}}')).rejects.toThrow(/svc-2/);
|
|
await expect(engine.healthCheckService('{{serviceId}}')).rejects.toMatchObject({
|
|
failingServices: ['svc-2'],
|
|
workflowResult: expect.objectContaining({ checked: 2, healthy: 1, failing: ['svc-2'] }),
|
|
});
|
|
});
|
|
|
|
test('gracefully degrades if read() throws — empty services list, no crash', async () => {
|
|
const engine = makeEngine({
|
|
servicesStateManager: {
|
|
read: jest.fn().mockRejectedValue(new Error('disk on fire')),
|
|
},
|
|
docker: undefined,
|
|
});
|
|
|
|
// Before the fix, this rejected because .read() wasn't called and the
|
|
// .catch(() => []) fallback didn't exist. Now it should resolve to empty.
|
|
const result = await engine.healthCheckService('{{serviceId}}');
|
|
expect(result).toEqual({ checked: 0, healthy: 0, results: [], failing: [] });
|
|
});
|
|
|
|
test('servicesStateManager absent on ctx → no crash, empty result', async () => {
|
|
const engine = new WorkflowEngine({
|
|
servicesStateManager: null,
|
|
docker: undefined,
|
|
});
|
|
// Same constructor cleanup
|
|
if (engine.scheduledJobs) {
|
|
for (const job of engine.scheduledJobs.values()) clearInterval(job);
|
|
engine.scheduledJobs.clear();
|
|
}
|
|
|
|
const result = await engine.healthCheckService('{{serviceId}}');
|
|
expect(result).toEqual({ checked: 0, healthy: 0, results: [], failing: [] });
|
|
});
|
|
|
|
test('single service (non-template serviceId) path still works', async () => {
|
|
const engine = makeEngine({
|
|
docker: {
|
|
client: {
|
|
getContainer: jest.fn(() => ({
|
|
inspect: jest.fn().mockResolvedValue({ State: { Running: true } }),
|
|
})),
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await engine.healthCheckService('single-svc-id');
|
|
expect(result).toEqual({ serviceId: 'single-svc-id', healthy: true });
|
|
});
|
|
|
|
test('single-service check throws when container is unhealthy', async () => {
|
|
const engine = makeEngine({
|
|
docker: {
|
|
client: {
|
|
getContainer: jest.fn(() => ({
|
|
inspect: jest.fn().mockResolvedValue({ State: { Running: false } }),
|
|
})),
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(engine.healthCheckService('down-svc')).rejects.toMatchObject({
|
|
failingServices: ['down-svc'],
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* DC-044 root-cause fix tests: notify-on-failure gating + template interpolation.
|
|
*
|
|
* The original code in executeAction had TWO latent bugs:
|
|
* 1. notify-on-failure sent unconditionally (its comment said "Only send if
|
|
* previous action failed" but the code never checked).
|
|
* 2. healthCheckService returned no serviceId field, so templates like
|
|
* `Health check failed for {{serviceId}}` never interpolated and stayed
|
|
* literal in every alert.
|
|
*
|
|
* These tests exercise the full executeWorkflow path with a stub workflow
|
|
* that pairs `health-check` with `notify-on-failure`.
|
|
*/
|
|
describe('WorkflowEngine._runActions — DC-044 root-cause (notify-on-failure + template)', () => {
|
|
// Build an engine and call _runActions directly with arbitrary action
|
|
// sequences. Bypasses BUNDLED_WORKFLOWS lookup so tests are isolated and
|
|
// don't mutate module state.
|
|
function makeEngine(opts = {}) {
|
|
const ctx = {
|
|
servicesStateManager: opts.servicesStateManager || { read: jest.fn().mockResolvedValue([]) },
|
|
docker: opts.docker || { client: { getContainer: jest.fn(() => ({ inspect: jest.fn().mockResolvedValue({ State: { Running: true } }) })) } },
|
|
notification: opts.notification || { send: jest.fn() },
|
|
};
|
|
const engine = new WorkflowEngine(ctx);
|
|
if (engine.scheduledJobs) {
|
|
for (const job of engine.scheduledJobs.values()) clearInterval(job);
|
|
engine.scheduledJobs.clear();
|
|
}
|
|
return engine;
|
|
}
|
|
|
|
test('notify-on-failure is a no-op when the previous action succeeded', async () => {
|
|
const notify = jest.fn();
|
|
const engine = makeEngine({
|
|
servicesStateManager: { read: jest.fn().mockResolvedValue([{ id: 'svc-1', containerId: 'c1' }]) },
|
|
docker: { client: { getContainer: jest.fn(() => ({
|
|
inspect: jest.fn().mockResolvedValue({ State: { Running: true } }),
|
|
})) } },
|
|
notification: { send: notify },
|
|
});
|
|
|
|
const results = await engine._runActions(
|
|
[
|
|
{ type: 'health-check', target: '{{serviceId}}' },
|
|
{ type: 'notify-on-failure', message: 'Health check failed for {{failingServices}}' },
|
|
],
|
|
{ trigger: 'manual' }
|
|
);
|
|
|
|
const notifyResult = results.find(r => r.action === 'notify-on-failure');
|
|
expect(notifyResult.success).toBe(true);
|
|
expect(notifyResult.result).toEqual({ skipped: true, reason: 'no previous failure' });
|
|
expect(notify).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('notify-on-failure fires and interpolates {{failingServices}} when previous action failed', async () => {
|
|
const notify = jest.fn();
|
|
const engine = makeEngine({
|
|
servicesStateManager: { read: jest.fn().mockResolvedValue([{ id: 'svc-broken', containerId: 'c1' }]) },
|
|
docker: { client: { getContainer: jest.fn(() => ({
|
|
inspect: jest.fn().mockResolvedValue({ State: { Running: false } }),
|
|
})) } },
|
|
notification: { send: notify },
|
|
});
|
|
|
|
const results = await engine._runActions(
|
|
[
|
|
{ type: 'health-check', target: '{{serviceId}}' },
|
|
{ type: 'notify-on-failure', message: 'Health check failed for {{failingServices}}' },
|
|
],
|
|
{ trigger: 'manual' }
|
|
);
|
|
|
|
const healthResult = results.find(r => r.action === 'health-check');
|
|
const notifyResult = results.find(r => r.action === 'notify-on-failure');
|
|
expect(healthResult.success).toBe(false);
|
|
expect(healthResult.failingServices).toEqual(['svc-broken']);
|
|
expect(notifyResult.success).toBe(true);
|
|
expect(notify).toHaveBeenCalledTimes(1);
|
|
// DC-094 notification.send signature: (event, { title, text }, level)
|
|
const sentMessage = notify.mock.calls[0][1].text;
|
|
expect(sentMessage).toBe('Health check failed for svc-broken');
|
|
expect(sentMessage).not.toContain('{{');
|
|
});
|
|
|
|
test('notify (not notify-on-failure) fires unconditionally — regression guard', async () => {
|
|
const notify = jest.fn();
|
|
const engine = makeEngine({ notification: { send: notify } });
|
|
|
|
const results = await engine._runActions(
|
|
[{ type: 'notify', message: 'always sent' }],
|
|
{ trigger: 'manual' }
|
|
);
|
|
|
|
expect(notify).toHaveBeenCalledTimes(1);
|
|
expect(notify.mock.calls[0][1].text).toBe('always sent');
|
|
expect(results[0].success).toBe(true);
|
|
});
|
|
|
|
test('notify-on-failure as first action is a no-op (no previous result)', async () => {
|
|
const notify = jest.fn();
|
|
const engine = makeEngine({ notification: { send: notify } });
|
|
|
|
const results = await engine._runActions(
|
|
[{ type: 'notify-on-failure', message: 'should not fire' }],
|
|
{ trigger: 'manual' }
|
|
);
|
|
|
|
const notifyResult = results[0];
|
|
expect(notifyResult.success).toBe(true);
|
|
expect(notifyResult.result).toEqual({ skipped: true, reason: 'no previous failure' });
|
|
expect(notify).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('multi-service batch failure: {{failingServices}} interpolates comma-joined list', async () => {
|
|
const notify = jest.fn();
|
|
const engine = makeEngine({
|
|
servicesStateManager: { read: jest.fn().mockResolvedValue([
|
|
{ id: 'svc-ok', containerId: 'c1' },
|
|
{ id: 'svc-broken-1', containerId: 'c2' },
|
|
{ id: 'svc-broken-2', containerId: 'c3' },
|
|
]) },
|
|
docker: { client: { getContainer: jest.fn((id) => ({
|
|
inspect: jest.fn().mockResolvedValue({
|
|
State: { Running: id === 'c1', Health: { Status: id === 'c1' ? 'healthy' : 'unhealthy' } },
|
|
}),
|
|
})) } },
|
|
notification: { send: notify },
|
|
});
|
|
|
|
const results = await engine._runActions(
|
|
[
|
|
{ type: 'health-check', target: '{{serviceId}}' },
|
|
{ type: 'notify-on-failure', message: 'Failing: {{failingServices}}' },
|
|
],
|
|
{ trigger: 'manual' }
|
|
);
|
|
|
|
expect(notify).toHaveBeenCalledTimes(1);
|
|
const sentMessage = notify.mock.calls[0][1].text;
|
|
expect(sentMessage).toBe('Failing: svc-broken-1,svc-broken-2');
|
|
expect(results.find(r => r.action === 'notify-on-failure').success).toBe(true);
|
|
});
|
|
|
|
// B2 regression: hit the actual bundled health-check-on-interval workflow
|
|
// end-to-end via executeWorkflow. The bundled template uses
|
|
// {{failingServices}} (DC-044 fix). Earlier it used {{serviceId}} which
|
|
// never resolved because no per-service ID is in workflow scope. This test
|
|
// would have failed with the old template.
|
|
test('executeWorkflow on bundled health-check-on-interval: no literal {{...}} in notification', async () => {
|
|
const { BUNDLED_WORKFLOWS } = require('../src/recipes/bundled-workflows');
|
|
expect(BUNDLED_WORKFLOWS['health-check-on-interval']).toBeDefined();
|
|
|
|
const notify = jest.fn();
|
|
const engine = makeEngine({
|
|
servicesStateManager: { read: jest.fn().mockResolvedValue([
|
|
{ id: 'svc-broken', containerId: 'c1' },
|
|
]) },
|
|
docker: { client: { getContainer: jest.fn(() => ({
|
|
inspect: jest.fn().mockResolvedValue({
|
|
State: { Running: false, Health: { Status: 'unhealthy' } },
|
|
}),
|
|
})) } },
|
|
notification: { send: notify },
|
|
});
|
|
|
|
const result = await engine.executeWorkflow('health-check-on-interval', { trigger: 'manual' });
|
|
|
|
// Either the bundled workflow fired notification (with interpolated
|
|
// message) OR every action resolved — but in NO case may a literal
|
|
// {{...}} template token leak into notification.send.
|
|
if (notify.mock.calls.length > 0) {
|
|
const sentMessage = notify.mock.calls[0][1].text;
|
|
expect(sentMessage).not.toMatch(/\{\{/);
|
|
expect(sentMessage).not.toMatch(/\}\}/);
|
|
// The new bundled template substitutes failingServices — make sure
|
|
// the actual service ID made it through.
|
|
expect(sentMessage).toContain('svc-broken');
|
|
}
|
|
// Workflow must always complete (success or failure), never throw.
|
|
expect(result).toBeDefined();
|
|
expect(result.workflowId).toBe('health-check-on-interval');
|
|
});
|
|
|
|
// B3 regression: a running container with Health.Status === 'unhealthy'
|
|
// must be reported as unhealthy. Previously checkContainerHealth compared
|
|
// info.State.Health itself (an object) to the string 'unhealthy', which
|
|
// was always false — so any container with an explicit healthcheck was
|
|
// always considered healthy. The fix reads info.State.Health.Status.
|
|
test('checkContainerHealth treats running-but-unhealthy container as unhealthy', async () => {
|
|
const engine = makeEngine({
|
|
docker: { client: { getContainer: jest.fn(() => ({
|
|
inspect: jest.fn().mockResolvedValue({
|
|
State: { Running: true, Health: { Status: 'unhealthy' } },
|
|
}),
|
|
})) } },
|
|
});
|
|
|
|
const healthy = await engine.checkContainerHealth('running-but-unhealthy');
|
|
expect(healthy).toBe(false);
|
|
});
|
|
|
|
test('checkContainerHealth treats running-with-no-healthcheck as healthy', async () => {
|
|
const engine = makeEngine({
|
|
docker: { client: { getContainer: jest.fn(() => ({
|
|
inspect: jest.fn().mockResolvedValue({ State: { Running: true } }),
|
|
})) } },
|
|
});
|
|
|
|
const healthy = await engine.checkContainerHealth('no-healthcheck');
|
|
expect(healthy).toBe(true);
|
|
});
|
|
|
|
test('checkContainerHealth treats stopped container as unhealthy', async () => {
|
|
const engine = makeEngine({
|
|
docker: { client: { getContainer: jest.fn(() => ({
|
|
inspect: jest.fn().mockResolvedValue({ State: { Running: false } }),
|
|
})) } },
|
|
});
|
|
|
|
const healthy = await engine.checkContainerHealth('stopped');
|
|
expect(healthy).toBe(false);
|
|
});
|
|
});
|