352 lines
16 KiB
TypeScript
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 });
|
|
}
|
|
});
|