Files
Codeman/test/routes/system-routes-keep-awake.test.ts
T
Codeman maintainer 233f85b170 feat(power): keep the machine awake while Codeman runs, lid close included
A laptop that suspends freezes every agent session until it wakes. The new
opt-in setting (App Settings > System > Power, off by default, "Only on AC
power" on by default) makes the server hold an OS sleep lock for exactly as
long as it runs:

- Linux: a logind block lock on handle-lid-switch:sleep:idle through
  systemd-inhibit. The lid needs the low-level handle-lid-switch lock
  (LidSwitchIgnoreInhibited=yes ignores a plain sleep lock), and polkit grants
  it only while someone is logged in to the desktop, so a refusal is reported
  as "denied" and retried every minute.
- macOS: caffeinate -i -s -w <pid>. Lid close needs pmset disablesleep, which
  an optional root helper (scripts/keep-awake-macos.sh, a LaunchDaemon run
  from a root-owned copy) applies while the server keeps a fresh request
  file, undoing only a disablesleep it set itself.

The lock dies with the server: systemd-inhibit runs cat on a stdin pipe, so
any exit (SIGKILL included, and under the unit's KillMode=process) releases
it; caffeinate exits with the pid it watches.

The installer asks on laptops (default no, never under --yes), and
`install.sh keep-awake` turns it on for an existing install. Status is at
GET /api/system/keep-awake; a non-admin's value is dropped in multi-user mode.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 07:21:57 +02:00

133 lines
4.8 KiB
TypeScript

/**
* @fileoverview Keep-awake wiring in system-routes: PUT /api/settings reconciles the sleep
* lock from the MERGED settings (a partial body never reads as "turn it off"), a
* non-admin's value is dropped in multi-user mode (it is machine state), and
* GET /api/system/keep-awake returns the manager's status in the standard envelope.
*
* Uses app.inject(); the manager is mocked, so no real lock is taken. Port: N/A.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
const { store, keepAwake } = vi.hoisted(() => ({
store: { settings: {} as Record<string, unknown>, written: [] as Record<string, unknown>[] },
keepAwake: {
apply: vi.fn(async () => {}),
getStatus: vi.fn(() => ({
enabled: true,
acOnly: true,
platform: 'linux',
state: 'active',
onAc: true,
lidHelper: null,
detail: null,
})),
stop: vi.fn(async () => {}),
},
}));
vi.mock('node:fs/promises', () => ({
default: {
readFile: vi.fn(async () => JSON.stringify(store.settings)),
writeFile: vi.fn(async (_path: string, data: string) => {
store.written.push(JSON.parse(data));
}),
},
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return { ...actual, existsSync: vi.fn(() => true), mkdirSync: vi.fn(), readdirSync: vi.fn(() => []) };
});
vi.mock('../../src/keep-awake-manager.js', () => ({ keepAwake }));
describe('keep-awake in system routes', () => {
let harness: RouteTestHarness;
beforeEach(() => {
store.settings = {};
store.written = [];
keepAwake.apply.mockClear();
delete process.env.CODEMAN_MULTIUSER;
});
afterEach(async () => {
delete process.env.CODEMAN_MULTIUSER;
await harness?.app.close();
});
it('a partial PUT keeps the persisted keep-awake on', async () => {
harness = await createRouteTestHarness(registerSystemRoutes);
store.settings = { keepAwakeEnabled: true, keepAwakeAcOnly: false };
const res = await harness.app.inject({ method: 'PUT', url: '/api/settings', payload: { showMonitor: true } });
expect(res.statusCode).toBe(200);
expect(keepAwake.apply).toHaveBeenCalledWith({ enabled: true, acOnly: false });
});
it('an explicit PUT turns it on and persists it', async () => {
harness = await createRouteTestHarness(registerSystemRoutes);
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { keepAwakeEnabled: true },
});
expect(res.statusCode).toBe(200);
expect(store.written.at(-1)).toMatchObject({ keepAwakeEnabled: true });
expect(keepAwake.apply).toHaveBeenCalledWith({ enabled: true, acOnly: true });
});
it('rejects a non-boolean value', async () => {
harness = await createRouteTestHarness(registerSystemRoutes);
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { keepAwakeEnabled: 'yes' },
});
expect(res.statusCode).toBe(400);
expect(keepAwake.apply).not.toHaveBeenCalled();
});
it("drops a non-admin's value in multi-user mode, without failing the rest of the save", async () => {
process.env.CODEMAN_MULTIUSER = '1';
harness = await createRouteTestHarness(registerSystemRoutes, {
authUser: { username: 'bob', role: 'user' },
});
store.settings = { keepAwakeEnabled: false };
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { keepAwakeEnabled: true, keepAwakeAcOnly: false, showMonitor: true },
});
expect(res.statusCode).toBe(200);
const written = store.written.at(-1)!;
expect(written.keepAwakeEnabled).toBe(false);
expect('keepAwakeAcOnly' in written).toBe(false);
expect(written.showMonitor).toBe(true);
expect(keepAwake.apply).toHaveBeenCalledWith({ enabled: false, acOnly: true });
});
it('an admin can change it in multi-user mode', async () => {
process.env.CODEMAN_MULTIUSER = '1';
harness = await createRouteTestHarness(registerSystemRoutes, {
authUser: { username: 'root', role: 'admin' },
});
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { keepAwakeEnabled: true },
});
expect(res.statusCode).toBe(200);
expect(keepAwake.apply).toHaveBeenCalledWith({ enabled: true, acOnly: true });
});
it('GET /api/system/keep-awake returns the status envelope', async () => {
harness = await createRouteTestHarness(registerSystemRoutes);
const res = await harness.app.inject({ method: 'GET', url: '/api/system/keep-awake' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ success: true, data: keepAwake.getStatus() });
});
});