mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 07:29:42 +02:00
Merge pull request #439
feat(remote): wake a sleeping host (Wake-on-LAN) from input, banner and native magic packet
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* @fileoverview Route tests for wake-on-LAN on `POST /api/sessions/:id/input`.
|
||||
*
|
||||
* The behavior that matters and cannot be tested at the registry level: a
|
||||
* wake-enabled remote session whose host is asleep must return 200 WITHOUT
|
||||
* writing into the stalled pane (the bytes would vanish), while every other
|
||||
* session keeps the historical fire-and-forget path untouched.
|
||||
*
|
||||
* The registry is injected through `registerSessionRoutes`'s test seam so no real
|
||||
* TCP connect, ssh, or WoL happens in CI.
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getDataDir } from '../../src/config/instance.js';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { registerSessionRoutes, _resetPaneLivenessState } from '../../src/web/routes/session-routes.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { createMockRouteContext } from '../mocks/index.js';
|
||||
import { httpStatusForErrorCode, type ApiErrorCode } from '../../src/types.js';
|
||||
import { sessionWaits } from '../../src/web/session-wait-registry.js';
|
||||
import { RemoteWakeRegistry, type RemoteWakeDeps } from '../../src/remote-wake.js';
|
||||
import type { SessionRemote } from '../../src/types.js';
|
||||
|
||||
const SESSION_ID = 'remote-wake-session';
|
||||
|
||||
/**
|
||||
* Mirror production's envelope + status mapping (as inbox-routes.test.ts does): a
|
||||
* returned `createErrorResponse` carries its 4xx, a plain object is wrapped in
|
||||
* `{success:true, data}`. Without it every error would read as a 200.
|
||||
*/
|
||||
function installEnvelope(app: FastifyInstance): void {
|
||||
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
|
||||
if (!req.url.startsWith('/api')) return done(null, payload);
|
||||
if (payload === null || typeof payload !== 'object') return done(null, payload);
|
||||
const p = payload as { success?: unknown; errorCode?: unknown };
|
||||
if (p.success === false) {
|
||||
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
|
||||
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
|
||||
}
|
||||
return done(null, payload);
|
||||
}
|
||||
if (p.success === true) return done(null, payload);
|
||||
return done(null, { success: true, data: payload });
|
||||
});
|
||||
}
|
||||
const URL = `/api/sessions/${SESSION_ID}/input`;
|
||||
|
||||
afterEach(() => {
|
||||
sessionWaits.cancelAll(SESSION_ID);
|
||||
_resetPaneLivenessState();
|
||||
});
|
||||
|
||||
interface Harness {
|
||||
app: FastifyInstance;
|
||||
ctx: ReturnType<typeof createMockRouteContext>;
|
||||
registry: RemoteWakeRegistry;
|
||||
probe: ReturnType<typeof vi.fn>;
|
||||
wake: ReturnType<typeof vi.fn>;
|
||||
events: string[];
|
||||
/** Let a held wake finish (see `holdWake`). */
|
||||
releaseWake: () => void;
|
||||
}
|
||||
|
||||
const remoteSession: SessionRemote = {
|
||||
hostId: 'hufflepuff',
|
||||
label: 'Hufflepuff',
|
||||
host: '192.168.50.137',
|
||||
username: 'j',
|
||||
remotePath: '/home/j/codeman-pi-test',
|
||||
wakeCommand: '/home/joe/bin/whuff',
|
||||
};
|
||||
|
||||
async function harness(
|
||||
opts: {
|
||||
remote?: SessionRemote;
|
||||
hostUp?: boolean;
|
||||
holdWake?: boolean;
|
||||
/** Stands in for the auth middleware (multi-user mode); absent = synthetic admin. */
|
||||
authUser?: { username: string; role: 'admin' | 'user' };
|
||||
} = {}
|
||||
): Promise<Harness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
if (opts.authUser) {
|
||||
const authUser = opts.authUser;
|
||||
app.addHook('onRequest', async (req) => {
|
||||
(req as unknown as { authUser: typeof authUser }).authUser = authUser;
|
||||
});
|
||||
}
|
||||
const ctx = createMockRouteContext({ sessionId: SESSION_ID });
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
session.remote = opts.remote ?? remoteSession;
|
||||
|
||||
const probe = vi.fn(async () => opts.hostUp ?? false);
|
||||
const wake = vi.fn(async () => true);
|
||||
const events: string[] = [];
|
||||
// With instantaneous mocks the whole wake chain (wake -> wait -> reattach ->
|
||||
// flush) can finish inside one `await`, so a test that wants to observe the
|
||||
// in-flight state has to hold the readiness poll open.
|
||||
let release: (() => void) | null = null;
|
||||
const deps: RemoteWakeDeps = {
|
||||
probe,
|
||||
wake,
|
||||
waitUntilReady: () =>
|
||||
opts.holdWake
|
||||
? new Promise<boolean>((resolve) => {
|
||||
release = () => resolve(true);
|
||||
})
|
||||
: Promise.resolve(true),
|
||||
delay: async () => {},
|
||||
noteReconnected: () => {},
|
||||
broadcast: (event) => events.push(event),
|
||||
log: () => {},
|
||||
};
|
||||
const registry = new RemoteWakeRegistry(deps);
|
||||
|
||||
registerSessionRoutes(app, ctx as never, { remoteWake: registry });
|
||||
installEnvelope(app);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return { app, ctx, registry, probe, wake, events, releaseWake: () => release?.() };
|
||||
}
|
||||
|
||||
const send = (app: FastifyInstance, payload: Record<string, unknown>) =>
|
||||
app.inject({ method: 'POST', url: URL, payload });
|
||||
|
||||
describe('POST /api/sessions/:id/input — wake-on-LAN', () => {
|
||||
it('buffers input instead of writing into a sleeping host, then flushes after the wake', async () => {
|
||||
const h = await harness({ hostUp: false, holdWake: true });
|
||||
const session = h.ctx.sessions.get(SESSION_ID)!;
|
||||
|
||||
const res = await send(h.app, { input: 'hallo', useMux: true });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ success: true, data: { buffered: true } });
|
||||
// Nothing reached the pane: writing now would be swallowed by the stalled ssh.
|
||||
expect(session.writeBuffer).toEqual([]);
|
||||
expect(h.wake).toHaveBeenCalledWith({ kind: 'command', command: '/home/joe/bin/whuff' });
|
||||
expect(h.registry.isWaking(SESSION_ID)).toBe(true);
|
||||
|
||||
h.releaseWake();
|
||||
await h.registry.wake(session);
|
||||
expect(session.writeBuffer).toEqual(['hallo']);
|
||||
expect(session.reattachRemote).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flushes several inputs typed during a wake IN ORDER (the browser posts one per keystroke)', async () => {
|
||||
// The concurrency surface that only exists in production: xterm's onData posts each
|
||||
// keystroke as its OWN request, so a wake collects N concurrent buffer writes and must
|
||||
// replay them in order. Route-level, so it is covered on every run instead of only in a
|
||||
// hand-driven browser session.
|
||||
const h = await harness({ hostUp: false, holdWake: true });
|
||||
const session = h.ctx.sessions.get(SESSION_ID)!;
|
||||
|
||||
for (const chunk of ['h', 'a', 'llo']) {
|
||||
const res = await send(h.app, { input: chunk, useMux: true });
|
||||
expect(res.statusCode).toBe(200);
|
||||
}
|
||||
// Nothing written while the host is asleep/dead — that is the whole point.
|
||||
expect(session.writeBuffer).toEqual([]);
|
||||
|
||||
h.releaseWake();
|
||||
await h.registry.wake(session);
|
||||
expect(session.writeBuffer).toEqual(['h', 'a', 'llo']);
|
||||
});
|
||||
|
||||
it('keeps the historical fire-and-forget write when the host is reachable', async () => {
|
||||
const h = await harness({ hostUp: true });
|
||||
const session = h.ctx.sessions.get(SESSION_ID)!;
|
||||
|
||||
const res = await send(h.app, { input: 'hallo', useMux: true });
|
||||
|
||||
expect(res.json()).toEqual({ success: true, data: {} }); // the historical bare answer, untouched
|
||||
await vi.waitFor(() => expect(session.writeBuffer).toEqual(['hallo']));
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
expect(session.reattachRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never probes or wakes a session without a wake command', async () => {
|
||||
const { wakeCommand, ...withoutWake } = remoteSession;
|
||||
const h = await harness({ remote: withoutWake as SessionRemote });
|
||||
const session = h.ctx.sessions.get(SESSION_ID)!;
|
||||
|
||||
await send(h.app, { input: 'hallo', useMux: true });
|
||||
|
||||
await vi.waitFor(() => expect(session.writeBuffer).toEqual(['hallo']));
|
||||
expect(h.probe).not.toHaveBeenCalled();
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes straight into a proxied host with a wake target: no probe, no buffer, no wake', async () => {
|
||||
// With a target configured, the old verdict buffered EVERY input for the life of
|
||||
// the session: the readiness poll can never succeed through a proxy, so nothing was
|
||||
// ever flushed (three inputs, nothing written, buffer non-empty — reproduced upstream).
|
||||
const h = await harness({ remote: { ...remoteSession, socksProxy: '127.0.0.1:1080' }, hostUp: false });
|
||||
const session = h.ctx.sessions.get(SESSION_ID)!;
|
||||
for (const input of ['a', 'b', 'c']) expect((await send(h.app, { input, useMux: true })).statusCode).toBe(200);
|
||||
expect(session.writeBuffer).toEqual(['a', 'b', 'c']);
|
||||
expect(h.probe).not.toHaveBeenCalled();
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
expect(h.registry.pendingBytes(SESSION_ID)).toBe(0);
|
||||
});
|
||||
|
||||
it('wakes before writing on the send-and-wait path (no buffering, the response waits anyway)', async () => {
|
||||
const h = await harness({ hostUp: false });
|
||||
const session = h.ctx.sessions.get(SESSION_ID)!;
|
||||
|
||||
await send(h.app, { input: 'hallo', useMux: true, wait: 'idle', waitTimeout: 60 });
|
||||
|
||||
expect(h.wake).toHaveBeenCalledTimes(1);
|
||||
// `ensureAwake` is awaited on this path, so the write happens inline and the
|
||||
// waiter is registered against a live pane.
|
||||
expect(session.writeBuffer).toEqual(['hallo']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/reachability', () => {
|
||||
const get = (app: FastifyInstance, url: string) => app.inject({ method: 'GET', url });
|
||||
|
||||
it('reports the probe result and how the host can be woken', async () => {
|
||||
const up = await harness({ hostUp: true });
|
||||
const upBody = (await get(up.app, `/api/sessions/${SESSION_ID}/reachability`)).json();
|
||||
expect(upBody.data.reachable).toBe(true);
|
||||
expect(upBody.data.wakeConfigured).toBe('command');
|
||||
expect(upBody.data.label).toBe('Hufflepuff');
|
||||
|
||||
const down = await harness({ hostUp: false });
|
||||
const downBody = (await get(down.app, `/api/sessions/${SESSION_ID}/reachability`)).json();
|
||||
expect(downBody.data.reachable).toBe(false);
|
||||
// A reachability check is a QUESTION, never an action: the host stays asleep.
|
||||
expect(down.wake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says nothing can wake a host without a configured target', async () => {
|
||||
const { wakeCommand, ...withoutWake } = remoteSession;
|
||||
const h = await harness({ remote: withoutWake as SessionRemote, hostUp: false });
|
||||
const body = (await get(h.app, `/api/sessions/${SESSION_ID}/reachability`)).json();
|
||||
expect(body.data.reachable).toBe(false);
|
||||
expect(body.data.wakeConfigured).toBe('none');
|
||||
});
|
||||
|
||||
it('reports a proxied host as unknown, not unreachable, and never probes it', async () => {
|
||||
// A jump-host / SOCKS host does not answer the bare TCP probe even while ssh works;
|
||||
// `reachable:false` here drew a permanent banner over a healthy session.
|
||||
const h = await harness({ remote: { ...remoteSession, jumpHost: 'bastion.example' }, hostUp: false });
|
||||
const body = (await get(h.app, `/api/sessions/${SESSION_ID}/reachability`)).json();
|
||||
expect(body.data.reachable).toBeNull();
|
||||
expect(body.data.probeable).toBe(false);
|
||||
expect(body.data.wakeConfigured).toBe('command');
|
||||
expect(h.probe).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/sessions/:id/wake', () => {
|
||||
const wake = (app: FastifyInstance) => app.inject({ method: 'POST', url: `/api/sessions/${SESSION_ID}/wake` });
|
||||
|
||||
it('wakes the host, reattaches the pane and reports both', async () => {
|
||||
const h = await harness({ hostUp: false });
|
||||
const session = h.ctx.sessions.get(SESSION_ID)!;
|
||||
|
||||
const body = (await wake(h.app)).json();
|
||||
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.woke).toBe(true);
|
||||
expect(body.data.reachable).toBe(true);
|
||||
expect(session.reattachRemote).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers with an error the UI can route to the config dialog', async () => {
|
||||
const { wakeCommand, ...withoutWake } = remoteSession;
|
||||
const h = await harness({ remote: withoutWake as SessionRemote, hostUp: false });
|
||||
|
||||
const res = await wake(h.app);
|
||||
const body = res.json();
|
||||
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toMatch(/No wake-on-LAN target/);
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not send a wake when the host answers, but still settles the session', async () => {
|
||||
const h = await harness({ hostUp: true });
|
||||
const body = (await wake(h.app)).json();
|
||||
expect(body.data.woke).toBe(true);
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/sessions + attachRemoteSession — authorization before the wake', () => {
|
||||
// Remote hosts are admin-only infra everywhere else, and the attach wake spawns the
|
||||
// host's `wakeCommand` (or broadcasts a packet). Before this gate a non-admin could
|
||||
// post an attach for any configured hostId, have that executable run and the request
|
||||
// held for the wake budget, and only THEN get a 403 for the workingDir (reproduced
|
||||
// upstream: wake spy fired once, response 403).
|
||||
it('403s a non-admin in multi-user mode without probing or waking the host', async () => {
|
||||
const prev = process.env.CODEMAN_MULTIUSER;
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
try {
|
||||
// `session-routes.ts` reads hosts from the sandboxed data dir (module-load-time
|
||||
// constant), so a host with a wake command is written THERE: a regression would
|
||||
// find it and fire the spy.
|
||||
await mkdir(getDataDir(), { recursive: true });
|
||||
await writeFile(
|
||||
join(getDataDir(), 'remote-hosts.json'),
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 'hufflepuff',
|
||||
label: 'Hufflepuff',
|
||||
host: '192.168.50.137',
|
||||
username: 'j',
|
||||
wakeCommand: '/home/joe/bin/whuff',
|
||||
},
|
||||
])
|
||||
);
|
||||
const h = await harness({ hostUp: false, authUser: { username: 'mallory', role: 'user' } });
|
||||
const res = await h.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { attachRemoteSession: { hostId: 'hufflepuff', remoteSessionName: 'codeman-abc12345' } },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.json().error).toMatch(/admin-only/);
|
||||
expect(h.probe).not.toHaveBeenCalled();
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
expect(h.events).toEqual([]);
|
||||
await h.app.close();
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.CODEMAN_MULTIUSER;
|
||||
else process.env.CODEMAN_MULTIUSER = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/sessions/:id/input — what the caller is told', () => {
|
||||
it('says buffered, and dropped for a chunk over the wake buffer cap', async () => {
|
||||
// The non-wait branch always answered a bare `{}`; these fields are additive. Without
|
||||
// them a prompt over 4 KB posted to a sleeping host was accepted and silently lost.
|
||||
const h = await harness({ hostUp: false, holdWake: true });
|
||||
const small = await send(h.app, { input: 'hallo', useMux: true });
|
||||
expect(small.statusCode).toBe(200);
|
||||
expect(small.json()).toEqual({ success: true, data: { buffered: true } });
|
||||
|
||||
const big = await send(h.app, { input: 'x'.repeat(5000), useMux: true });
|
||||
expect(big.statusCode).toBe(200);
|
||||
expect(big.json()).toEqual({ success: true, data: { buffered: true, dropped: true } });
|
||||
expect(h.registry.pendingBytes(SESSION_ID)).toBe(5);
|
||||
h.releaseWake();
|
||||
await h.registry.wake(h.ctx.sessions.get(SESSION_ID)!);
|
||||
});
|
||||
|
||||
it('fails the send-and-wait path when the host never comes back, instead of writing into the stalled pane', async () => {
|
||||
// Readiness never arrives: the wake resolves false.
|
||||
const failing = await harnessWithFailingWake();
|
||||
const session = failing.ctx.sessions.get(SESSION_ID)!;
|
||||
const res = await send(failing.app, { input: 'hallo', useMux: true, wait: true, waitTimeout: 1000 });
|
||||
expect(res.statusCode).toBe(422);
|
||||
expect(res.json().errorCode).toBe('OPERATION_FAILED');
|
||||
expect(res.json().error).toMatch(/did not come back/);
|
||||
expect(session.writeBuffer).toEqual([]);
|
||||
await failing.app.close();
|
||||
});
|
||||
});
|
||||
|
||||
/** A harness whose readiness poll answers false: the wake command runs, the host stays down. */
|
||||
async function harnessWithFailingWake(): Promise<Harness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
const ctx = createMockRouteContext({ sessionId: SESSION_ID });
|
||||
ctx.sessions.get(SESSION_ID)!.remote = remoteSession;
|
||||
const probe = vi.fn(async () => false);
|
||||
const wake = vi.fn(async () => true);
|
||||
const events: string[] = [];
|
||||
const registry = new RemoteWakeRegistry({
|
||||
probe,
|
||||
wake,
|
||||
waitUntilReady: async () => false,
|
||||
delay: async () => {},
|
||||
noteReconnected: () => {},
|
||||
broadcast: (event) => events.push(event),
|
||||
log: () => {},
|
||||
});
|
||||
registerSessionRoutes(app, ctx as never, { remoteWake: registry });
|
||||
installEnvelope(app);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return { app, ctx, registry, probe, wake, events, releaseWake: () => {} };
|
||||
}
|
||||
@@ -55,6 +55,7 @@ vi.mock('../../src/remote-hosts.js', async (orig) => {
|
||||
});
|
||||
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
import { RemoteWakeRegistry, REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS } from '../../src/remote-wake.js';
|
||||
import { resolveTerminalHistoryConfig } from '../../src/config/terminal-history.js';
|
||||
|
||||
interface LocalHarness {
|
||||
@@ -62,6 +63,15 @@ interface LocalHarness {
|
||||
ctx: MockRouteContext;
|
||||
}
|
||||
|
||||
// Wake-on-LAN seam: the production registry opens a real TCP connection to the host
|
||||
// and can run a real wake command, so every route registered here gets a fake one
|
||||
// (the same seam `test/routes/session-remote-wake.test.ts` uses). Default: the host
|
||||
// answers, so nothing ever wakes.
|
||||
const wakeProbe = vi.fn(async () => true);
|
||||
const wakeCommandRun = vi.fn(async () => true);
|
||||
const wakeWaitUntilReady = vi.fn(async () => true);
|
||||
let wakeRegistry: RemoteWakeRegistry;
|
||||
|
||||
/**
|
||||
* Build a Fastify instance that mirrors production's uniform-envelope behavior
|
||||
* (server.ts preSerialization hook) so the test wire format matches the contract:
|
||||
@@ -108,7 +118,17 @@ describe('session-routes', () => {
|
||||
let harness: LocalHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createEnvelopeHarness(registerSessionRoutes);
|
||||
wakeProbe.mockReset().mockResolvedValue(true);
|
||||
wakeCommandRun.mockReset().mockResolvedValue(true);
|
||||
wakeWaitUntilReady.mockReset().mockResolvedValue(true);
|
||||
wakeRegistry = new RemoteWakeRegistry({
|
||||
probe: wakeProbe,
|
||||
wake: wakeCommandRun,
|
||||
waitUntilReady: wakeWaitUntilReady,
|
||||
delay: async () => {},
|
||||
log: () => {},
|
||||
});
|
||||
harness = await createEnvelopeHarness((app, ctx) => registerSessionRoutes(app, ctx, { remoteWake: wakeRegistry }));
|
||||
// Reset remote store so tests start with empty hosts/cases and a passing tmux probe
|
||||
remoteStore.hosts = [];
|
||||
remoteStore.cases = [];
|
||||
@@ -1990,6 +2010,134 @@ describe('session-routes', () => {
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.OPERATION_FAILED });
|
||||
});
|
||||
|
||||
describe('remote create/attach wakes a sleeping host (Wake-on-LAN)', () => {
|
||||
const host = (extra: Record<string, unknown> = {}) => ({
|
||||
id: 'hufflepuff',
|
||||
label: 'Hufflepuff',
|
||||
host: '192.168.50.137',
|
||||
username: 'j',
|
||||
wakeMac: '04:d9:f5:80:c6:58',
|
||||
...extra,
|
||||
});
|
||||
const remoteCase = { name: 'hufflepuff-work', type: 'remote', hostId: 'hufflepuff', remotePath: '/home/j/work' };
|
||||
const quickStart = () =>
|
||||
harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/quick-start',
|
||||
payload: { caseName: 'hufflepuff-work', mode: 'shell' },
|
||||
});
|
||||
|
||||
it('wakes the host before the tmux probe when the user runs a remote case', async () => {
|
||||
const startShell = vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
|
||||
try {
|
||||
remoteStore.hosts = [host()];
|
||||
remoteStore.cases = [remoteCase];
|
||||
wakeProbe.mockResolvedValue(false); // asleep
|
||||
|
||||
const res = await quickStart();
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body).success).toBe(true);
|
||||
expect(wakeCommandRun).toHaveBeenCalledWith({ kind: 'mac', macs: [[4, 217, 245, 128, 198, 88]] });
|
||||
// The request budget, not the 90 s session default: the reverse proxy would
|
||||
// cut the request at 60 s while the session was still being built.
|
||||
expect(wakeWaitUntilReady).toHaveBeenCalledWith(expect.objectContaining({ hostId: 'hufflepuff' }), {
|
||||
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
||||
// The shutdown signal rides along so `WebServer.stop()` can end the poll.
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
} finally {
|
||||
startShell.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not wake a host that answers, and never probes a host without a wake target', async () => {
|
||||
const startShell = vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
|
||||
try {
|
||||
remoteStore.hosts = [host()];
|
||||
remoteStore.cases = [remoteCase];
|
||||
// The fake probe answers `true` by default — a reachable host.
|
||||
expect((await quickStart()).statusCode).toBe(200);
|
||||
expect(wakeCommandRun).not.toHaveBeenCalled();
|
||||
|
||||
// No wake target at all: not even a probe, so hosts without WoL keep the
|
||||
// exact behavior (and latency) they had before this feature.
|
||||
wakeProbe.mockClear();
|
||||
remoteStore.hosts = [host({ wakeMac: undefined })];
|
||||
expect((await quickStart()).statusCode).toBe(200);
|
||||
expect(wakeProbe).not.toHaveBeenCalled();
|
||||
expect(wakeCommandRun).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
startShell.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses the run when the host never comes back, and starts no session', async () => {
|
||||
remoteStore.hosts = [host()];
|
||||
remoteStore.cases = [remoteCase];
|
||||
wakeProbe.mockResolvedValue(false);
|
||||
wakeWaitUntilReady.mockResolvedValue(false);
|
||||
const sessionsBefore = harness.ctx.sessions.size;
|
||||
|
||||
const res = await quickStart();
|
||||
|
||||
expect(res.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.OPERATION_FAILED));
|
||||
expect(JSON.parse(res.body).error).toMatch(/did not come back after a wake-on-LAN request/);
|
||||
// No half-created session: the failure is the answer, not a dead tab.
|
||||
expect(harness.ctx.sessions.size).toBe(sessionsBefore);
|
||||
});
|
||||
|
||||
it('blames the sleeping host, not tmux, when the host has no wake target', async () => {
|
||||
remoteStore.hosts = [host({ wakeMac: undefined })];
|
||||
remoteStore.cases = [remoteCase];
|
||||
remoteStore.tmuxCheck = {
|
||||
ok: false,
|
||||
error: 'remote host 192.168.50.137 needs tmux installed for durable remote sessions',
|
||||
};
|
||||
wakeProbe.mockResolvedValue(false);
|
||||
|
||||
const res = await quickStart();
|
||||
|
||||
expect(res.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.OPERATION_FAILED));
|
||||
expect(JSON.parse(res.body).error).toMatch(/has no wake-on-LAN target/);
|
||||
});
|
||||
|
||||
it('keeps the tmux error when the host is up but tmux is really missing', async () => {
|
||||
remoteStore.hosts = [host()];
|
||||
remoteStore.cases = [remoteCase];
|
||||
remoteStore.tmuxCheck = {
|
||||
ok: false,
|
||||
error: 'remote host 192.168.50.137 needs tmux installed for durable remote sessions',
|
||||
};
|
||||
// Probe answers `true`: the ssh failure is genuinely about tmux.
|
||||
|
||||
const res = await quickStart();
|
||||
|
||||
expect(JSON.parse(res.body).error).toMatch(/needs tmux installed/);
|
||||
});
|
||||
|
||||
it('wakes the host when attaching to a discovered remote session', async () => {
|
||||
const startInteractive = vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
|
||||
const startShell = vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
|
||||
try {
|
||||
remoteStore.hosts = [host()];
|
||||
wakeProbe.mockResolvedValue(false);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { attachRemoteSession: { hostId: 'hufflepuff', remoteSessionName: 'codeman-abc12345' } },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(wakeCommandRun).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
startInteractive.mockRestore();
|
||||
startShell.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('does not run local codex availability check for a remote codex case', async () => {
|
||||
// A remote codex case must NOT be blocked by the LOCAL codex availability gate
|
||||
// (the CLI runs on the remote host). Probe is stubbed ok in remoteStore.tmuxCheck.
|
||||
|
||||
Reference in New Issue
Block a user