Initial import
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { env } from '../config/env.js';
|
||||
import {
|
||||
buildHealthCheckUrls,
|
||||
buildRestartFailureMessage,
|
||||
buildServerCommandApiRestartUrl,
|
||||
listServerCommands,
|
||||
resolveDockerSocketPath,
|
||||
restartServerCommand,
|
||||
} from './server-command-service.js';
|
||||
|
||||
test('buildRestartFailureMessage includes exit info and stderr output', () => {
|
||||
const message = buildRestartFailureMessage(
|
||||
'TEST',
|
||||
Object.assign(new Error('Command failed'), {
|
||||
code: 1,
|
||||
stderr: 'no such service: app',
|
||||
stdout: '',
|
||||
}),
|
||||
);
|
||||
|
||||
assert.match(message, /TEST 재기동에 실패했습니다\./);
|
||||
assert.match(message, /exit:1/);
|
||||
assert.match(message, /no such service: app/);
|
||||
});
|
||||
|
||||
test('listServerCommands uses app as the default test restart service', async () => {
|
||||
const commands = await listServerCommands();
|
||||
const testCommand = commands.find((item) => item.key === 'test');
|
||||
|
||||
assert.ok(testCommand);
|
||||
assert.equal(testCommand.serviceName, 'app');
|
||||
});
|
||||
|
||||
test('listServerCommands resolves restart script from main project when project root fallback is needed', async () => {
|
||||
const commands = await listServerCommands();
|
||||
const testCommand = commands.find((item) => item.key === 'test');
|
||||
|
||||
assert.ok(testCommand);
|
||||
assert.match(testCommand.commandScript, /\/etc\/commands\/server-command\/restart-test\.sh$/);
|
||||
assert.notEqual(testCommand.commandScript, '/etc/commands/server-command/restart-test.sh');
|
||||
});
|
||||
|
||||
test('test and release restart scripts fall back to Docker socket when docker CLI is unavailable', () => {
|
||||
const commandsRoot = new URL('../../../../commands/server-command/', import.meta.url);
|
||||
const testScript = fs.readFileSync(new URL('restart-test.sh', commandsRoot), 'utf8');
|
||||
const relScript = fs.readFileSync(new URL('restart-rel.sh', commandsRoot), 'utf8');
|
||||
const workServerScript = fs.readFileSync(new URL('restart-work-server.sh', commandsRoot), 'utf8');
|
||||
const socketRestartScript = fs.readFileSync(new URL('restart-via-docker-socket.mjs', commandsRoot), 'utf8');
|
||||
|
||||
assert.match(testScript, /command -v docker >/);
|
||||
assert.match(testScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" restart "\$SERVER_COMMAND_SERVICE"/);
|
||||
assert.match(testScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "\$SERVER_COMMAND_SERVICE"/);
|
||||
assert.match(testScript, /restart-via-docker-socket\.mjs/);
|
||||
assert.match(testScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-app-1\}"/);
|
||||
assert.match(relScript, /command -v docker >/);
|
||||
assert.match(relScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" restart "\$SERVER_COMMAND_SERVICE"/);
|
||||
assert.match(relScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "\$SERVER_COMMAND_SERVICE"/);
|
||||
assert.match(relScript, /restart-via-docker-socket\.mjs/);
|
||||
assert.match(relScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-release\}"/);
|
||||
assert.match(
|
||||
workServerScript,
|
||||
/docker compose -f etc\/servers\/work-server\/docker-compose\.yml up -d --build --force-recreate --no-deps work-server/,
|
||||
);
|
||||
assert.match(socketRestartScript, /\/containers\/\$\{encodeURIComponent\(containerName\)\}\/restart\?t=30/);
|
||||
});
|
||||
|
||||
test('work-server package dev script does not use watch mode and rebuilds before start', async () => {
|
||||
const packageJsonPath = new URL('../../package.json', import.meta.url);
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as {
|
||||
scripts?: Record<string, string>;
|
||||
};
|
||||
|
||||
assert.equal(packageJson.scripts?.dev, 'npm run build && npm run start');
|
||||
assert.doesNotMatch(String(packageJson.scripts?.dev ?? ''), /\bwatch\b/i);
|
||||
});
|
||||
|
||||
test('buildServerCommandApiRestartUrl replaces key placeholders on configured command api endpoint', () => {
|
||||
assert.equal(
|
||||
buildServerCommandApiRestartUrl('http://127.0.0.1:3200/', '/commands/{key}/restart', 'work-server'),
|
||||
'http://127.0.0.1:3200/commands/work-server/restart',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildServerCommandApiRestartUrl avoids duplicating an existing base path prefix', () => {
|
||||
assert.equal(
|
||||
buildServerCommandApiRestartUrl(
|
||||
'http://127.0.0.1:3211/api',
|
||||
'/api/server-commands/{key}/actions/restart',
|
||||
'test',
|
||||
),
|
||||
'http://127.0.0.1:3211/api/server-commands/test/actions/restart',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildHealthCheckUrls adds localhost fallbacks for command-runner', () => {
|
||||
assert.deepEqual(buildHealthCheckUrls('command-runner', 'http://host.docker.internal:3211/health'), [
|
||||
'http://host.docker.internal:3211/health',
|
||||
'http://127.0.0.1:3211/health',
|
||||
'http://localhost:3211/health',
|
||||
]);
|
||||
assert.deepEqual(buildHealthCheckUrls('work-server', 'http://host.docker.internal:3100/health'), [
|
||||
'http://host.docker.internal:3100/health',
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolveDockerSocketPath prefers explicit socket path and falls back to unix DOCKER_HOST', () => {
|
||||
assert.equal(
|
||||
resolveDockerSocketPath({
|
||||
SERVER_COMMAND_DOCKER_SOCKET: '/custom/docker.sock',
|
||||
DOCKER_HOST: 'unix:///run/user/1000/docker.sock',
|
||||
}),
|
||||
'/custom/docker.sock',
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
resolveDockerSocketPath({
|
||||
DOCKER_HOST: 'unix:///run/user/1000/docker.sock',
|
||||
}),
|
||||
'/run/user/1000/docker.sock',
|
||||
);
|
||||
});
|
||||
|
||||
test('restartServerCommand delegates to configured command api when base url is provided', async (t) => {
|
||||
const originalBaseUrl = env.SERVER_COMMAND_API_BASE_URL;
|
||||
const originalAccessToken = env.SERVER_COMMAND_API_ACCESS_TOKEN;
|
||||
const originalPathTemplate = env.SERVER_COMMAND_API_RESTART_PATH_TEMPLATE;
|
||||
|
||||
env.SERVER_COMMAND_API_BASE_URL = 'http://127.0.0.1:3200/api';
|
||||
env.SERVER_COMMAND_API_ACCESS_TOKEN = 'local-command-token';
|
||||
env.SERVER_COMMAND_API_RESTART_PATH_TEMPLATE = '/commands/{key}/restart';
|
||||
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (input: string | URL | Request, init?: RequestInit) => {
|
||||
assert.equal(String(input), 'http://127.0.0.1:3200/api/commands/test/restart');
|
||||
assert.equal(init?.method, 'POST');
|
||||
const headers = new Headers(init?.headers);
|
||||
assert.equal(headers.get('X-Access-Token'), 'local-command-token');
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
restartState: 'accepted',
|
||||
item: {
|
||||
key: 'test',
|
||||
label: 'TEST',
|
||||
composeStatus: 'restarting',
|
||||
},
|
||||
commandOutput: 'restart accepted',
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await restartServerCommand('test');
|
||||
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
assert.equal(result.restartState, 'accepted');
|
||||
assert.equal(result.commandOutput, 'restart accepted');
|
||||
assert.equal(result.server.key, 'test');
|
||||
assert.equal(result.server.composeStatus, 'restarting');
|
||||
} finally {
|
||||
env.SERVER_COMMAND_API_BASE_URL = originalBaseUrl;
|
||||
env.SERVER_COMMAND_API_ACCESS_TOKEN = originalAccessToken;
|
||||
env.SERVER_COMMAND_API_RESTART_PATH_TEMPLATE = originalPathTemplate;
|
||||
}
|
||||
});
|
||||
|
||||
test('restartServerCommand surfaces deferred restart script failures for work-server', async (t) => {
|
||||
const originalBaseUrl = env.SERVER_COMMAND_API_BASE_URL;
|
||||
env.SERVER_COMMAND_API_BASE_URL = '';
|
||||
const childProcessModule = (await import('node:child_process')) as { spawn: (...args: unknown[]) => unknown };
|
||||
|
||||
const spawnMock = t.mock.method(
|
||||
childProcessModule,
|
||||
'spawn',
|
||||
(...spawnArgs: unknown[]) => {
|
||||
const args = Array.isArray(spawnArgs[1]) ? (spawnArgs[1] as string[]) : [];
|
||||
const shellCommand = String(args[1] ?? '');
|
||||
const logPath = shellCommand.match(/>"([^"]+\.log)"/)?.[1] ?? '';
|
||||
const statusPath = shellCommand.match(/>"([^"]+\.status)"/)?.[1] ?? '';
|
||||
|
||||
queueMicrotask(() => {
|
||||
void writeFile(statusPath, '1', 'utf8');
|
||||
void writeFile(logPath, 'docker compose failed', 'utf8');
|
||||
});
|
||||
|
||||
const child = new EventEmitter() as EventEmitter & { unref(): void };
|
||||
child.unref = () => undefined;
|
||||
queueMicrotask(() => {
|
||||
child.emit('spawn');
|
||||
});
|
||||
return child;
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await assert.rejects(() => restartServerCommand('work-server'), /WORK-SERVER 재기동에 실패했습니다\./);
|
||||
assert.equal(spawnMock.mock.callCount(), 1);
|
||||
} finally {
|
||||
env.SERVER_COMMAND_API_BASE_URL = originalBaseUrl;
|
||||
}
|
||||
});
|
||||
|
||||
test('listServerCommands marks command-runner online when localhost fallback responds', async (t) => {
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (input: string | URL | Request) => {
|
||||
const url = String(input);
|
||||
|
||||
if (url === 'http://host.docker.internal:3211/health') {
|
||||
throw new Error('fetch failed');
|
||||
}
|
||||
|
||||
if (url === 'http://127.0.0.1:3211/health') {
|
||||
return new Response(JSON.stringify({ ok: true, service: 'server-command-runner' }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response('ok', { status: 200 });
|
||||
});
|
||||
|
||||
const commands = await listServerCommands();
|
||||
const runnerCommand = commands.find((item) => item.key === 'command-runner');
|
||||
|
||||
assert.ok(runnerCommand);
|
||||
assert.equal(fetchMock.mock.calls.some((call) => String(call.arguments[0]) === 'http://host.docker.internal:3211/health'), true);
|
||||
assert.equal(fetchMock.mock.calls.some((call) => String(call.arguments[0]) === 'http://127.0.0.1:3211/health'), true);
|
||||
assert.equal(runnerCommand.availability, 'online');
|
||||
assert.equal(runnerCommand.httpStatus, 200);
|
||||
assert.match(String(runnerCommand.errorMessage ?? ''), /fallback health check succeeded via http:\/\/127\.0\.0\.1:3211\/health/);
|
||||
});
|
||||
Reference in New Issue
Block a user