Files
ai-code-app/etc/servers/work-server/src/services/server-command-service.test.ts

352 lines
16 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { EventEmitter } from 'node:events';
import { mkdtemp, mkdir, rm, 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 exposes prod restart command', async () => {
const commands = await listServerCommands();
const prodCommand = commands.find((item) => item.key === 'prod');
assert.ok(prodCommand);
assert.equal(prodCommand.serviceName, 'prod-app');
assert.match(prodCommand.commandScript, /\/etc\/commands\/server-command\/restart-prod\.sh$/);
});
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, release and prod 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 prodScript = fs.readFileSync(new URL('restart-prod.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, /git fetch "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/);
assert.match(testScript, /git pull --ff-only "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/);
assert.match(testScript, /SERVER_COMMAND_TEST_GIT_REMOTE="\$\{SERVER_COMMAND_TEST_GIT_REMOTE:-origin\}"/);
assert.match(testScript, /SERVER_COMMAND_TEST_GIT_BRANCH="\$\{SERVER_COMMAND_TEST_GIT_BRANCH:-main\}"/);
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" up -d --build --force-recreate --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(prodScript, /command -v docker >/);
assert.match(prodScript, /git pull --ff-only "\$SERVER_COMMAND_PROD_GIT_REMOTE" "\$SERVER_COMMAND_PROD_GIT_BRANCH"/);
assert.match(prodScript, /SERVER_COMMAND_PROD_GIT_REMOTE="\$\{SERVER_COMMAND_PROD_GIT_REMOTE:-origin\}"/);
assert.match(prodScript, /SERVER_COMMAND_PROD_GIT_BRANCH="\$\{SERVER_COMMAND_PROD_GIT_BRANCH:-main\}"/);
assert.match(
prodScript,
/docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$SERVER_COMMAND_SERVICE"/,
);
assert.match(prodScript, /restart-via-docker-socket\.mjs/);
assert.match(prodScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-prod\}"/);
assert.match(
workServerScript,
/docker compose -f etc\/servers\/work-server\/docker-compose\.yml up -d --build --force-recreate --no-deps work-server/,
);
assert.doesNotMatch(workServerScript, /kill -HUP 1/);
assert.match(socketRestartScript, /\/containers\/\$\{encodeURIComponent\(containerName\)\}\/restart\?t=30/);
});
test('prod restart script pulls the configured remote main branch before restart', () => {
const commandsRoot = new URL('../../../../commands/server-command/', import.meta.url);
const prodScript = fs.readFileSync(new URL('restart-prod.sh', commandsRoot), 'utf8');
assert.match(prodScript, /CURRENT_BRANCH=\$\(git rev-parse --abbrev-ref HEAD 2>\/dev\/null \|\| true\)/);
assert.match(prodScript, /git -c "credential\.helper=store --file=\$CREDENTIAL_STORE" pull --ff-only/);
assert.match(prodScript, /git pull --ff-only "\$SERVER_COMMAND_PROD_GIT_REMOTE" "\$SERVER_COMMAND_PROD_GIT_BRANCH"/);
});
test('test restart script pulls the configured remote main branch before restart', () => {
const commandsRoot = new URL('../../../../commands/server-command/', import.meta.url);
const testScript = fs.readFileSync(new URL('restart-test.sh', commandsRoot), 'utf8');
assert.match(testScript, /git fetch "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/);
assert.match(testScript, /git switch "\$SERVER_COMMAND_TEST_GIT_BRANCH"/);
assert.match(testScript, /git pull --ff-only "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/);
});
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/);
});
test('listServerCommands ignores public codex chat resources when checking app source updates', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'ai-code-app-source-scan-'));
const originalMainRoot = env.SERVER_COMMAND_MAIN_PROJECT_ROOT;
const originalProjectRoot = env.SERVER_COMMAND_PROJECT_ROOT;
const buildTargetPath = '/tmp/ai-code-test-app-dist/index.html';
const previousBuildContents = fs.existsSync(buildTargetPath) ? await fs.promises.readFile(buildTargetPath) : null;
const previousBuildStat = fs.existsSync(buildTargetPath) ? await fs.promises.stat(buildTargetPath) : null;
try {
const staleDate = new Date('2026-04-19T00:00:00.000Z');
await mkdir(path.join(tempRoot, 'src'), { recursive: true });
await mkdir(path.join(tempRoot, 'public', '.codex_chat', 'session', 'resource'), { recursive: true });
await writeFile(path.join(tempRoot, 'src', 'main.tsx'), 'export const app = true;\n', 'utf8');
await writeFile(path.join(tempRoot, 'index.html'), '<!doctype html>\n', 'utf8');
await writeFile(path.join(tempRoot, 'package.json'), '{"name":"tmp"}\n', 'utf8');
await writeFile(path.join(tempRoot, 'tsconfig.json'), '{}\n', 'utf8');
await writeFile(path.join(tempRoot, 'tsconfig.app.json'), '{}\n', 'utf8');
await writeFile(path.join(tempRoot, 'vite.config.ts'), 'export default {};\n', 'utf8');
await writeFile(path.join(tempRoot, 'public', '.codex_chat', 'session', 'resource', 'note.txt'), 'resource only\n', 'utf8');
await Promise.all([
fs.promises.utimes(path.join(tempRoot, 'src', 'main.tsx'), staleDate, staleDate),
fs.promises.utimes(path.join(tempRoot, 'index.html'), staleDate, staleDate),
fs.promises.utimes(path.join(tempRoot, 'package.json'), staleDate, staleDate),
fs.promises.utimes(path.join(tempRoot, 'tsconfig.json'), staleDate, staleDate),
fs.promises.utimes(path.join(tempRoot, 'tsconfig.app.json'), staleDate, staleDate),
fs.promises.utimes(path.join(tempRoot, 'vite.config.ts'), staleDate, staleDate),
]);
await mkdir(path.dirname(buildTargetPath), { recursive: true });
await writeFile(buildTargetPath, '<!doctype html>\n', 'utf8');
const buildDate = new Date('2026-04-20T00:00:00.000Z');
await fs.promises.utimes(buildTargetPath, buildDate, buildDate);
const resourceDate = new Date('2026-04-28T00:00:00.000Z');
await fs.promises.utimes(path.join(tempRoot, 'public', '.codex_chat', 'session', 'resource', 'note.txt'), resourceDate, resourceDate);
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = tempRoot;
env.SERVER_COMMAND_PROJECT_ROOT = tempRoot;
const commands = await listServerCommands();
const testCommand = commands.find((item) => item.key === 'test');
assert.ok(testCommand);
assert.equal(testCommand.buildRequired, false);
assert.notEqual(testCommand.latestSourceChangePath, 'public/.codex_chat/session/resource/note.txt');
} finally {
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot;
env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot;
if (previousBuildContents) {
await writeFile(buildTargetPath, previousBuildContents, 'utf8');
if (previousBuildStat) {
await fs.promises.utimes(buildTargetPath, previousBuildStat.atime, previousBuildStat.mtime);
}
} else {
await rm(buildTargetPath, { force: true });
}
await rm(tempRoot, { recursive: true, force: true });
}
});