Initial import
This commit is contained in:
260
etc/servers/work-server/src/services/chat-service.test.ts
Normal file
260
etc/servers/work-server/src/services/chat-service.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { env } from '../config/env.js';
|
||||
import {
|
||||
collectOfflineNotificationClientIds,
|
||||
createActivityLogMessage,
|
||||
extractDiffCodeBlocks,
|
||||
fitActivityLogLines,
|
||||
isAutomationRegistrationCountRequest,
|
||||
resolveResponseTimestamp,
|
||||
rewriteCodexOutputWithChatResources,
|
||||
shouldUseAgenticCodexReply,
|
||||
shouldUseTemplateMacroReply,
|
||||
validateAgenticCodexRuntime,
|
||||
} from './chat-service.js';
|
||||
|
||||
test('collectOfflineNotificationClientIds merges session and conversation targets without duplicates', () => {
|
||||
assert.deepEqual(
|
||||
collectOfflineNotificationClientIds('client-a', ['client-b', ' client-a ', '', 'client-c', 'client-b']),
|
||||
['client-b', 'client-a', 'client-c'],
|
||||
);
|
||||
});
|
||||
|
||||
test('isAutomationRegistrationCountRequest detects today automation registration count questions', () => {
|
||||
assert.equal(isAutomationRegistrationCountRequest('오늘 자동화 등록 총 건수'), true);
|
||||
assert.equal(isAutomationRegistrationCountRequest('today automation register count'), true);
|
||||
assert.equal(isAutomationRegistrationCountRequest('자동화 등록 기준이 뭐야'), false);
|
||||
});
|
||||
|
||||
test('shouldUseAgenticCodexReply routes read and modify style requests to real Codex execution', () => {
|
||||
assert.equal(shouldUseAgenticCodexReply('src/app/main/MainChatPanel.tsx 읽어서 구조 설명해줘'), true);
|
||||
assert.equal(shouldUseAgenticCodexReply('DB 직접 조회해서 오늘 오류 건수 확인해줘'), true);
|
||||
assert.equal(shouldUseAgenticCodexReply('MainChatPanel.hotfix.css 수정해줘'), true);
|
||||
});
|
||||
|
||||
test('shouldUseAgenticCodexReply keeps fast-path responses for automation registration count questions', () => {
|
||||
assert.equal(shouldUseAgenticCodexReply('오늘 자동화 등록 총 건수'), false);
|
||||
});
|
||||
|
||||
test('shouldUseTemplateMacroReply only matches template chats and template-scoped prompts', () => {
|
||||
assert.equal(
|
||||
shouldUseTemplateMacroReply(
|
||||
{
|
||||
pageId: null,
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://test.sm-home.cloud/chat/live',
|
||||
chatTypeLabel: 'API 요청 템플릿',
|
||||
chatTypeDescription: 'API 요청 본문을 정리하는 템플릿',
|
||||
chatTypeIsTemplate: true,
|
||||
},
|
||||
'이 템플릿 예시 보여줘',
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldUseTemplateMacroReply(
|
||||
{
|
||||
pageId: null,
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://test.sm-home.cloud/chat/live',
|
||||
chatTypeLabel: 'API 요청 템플릿',
|
||||
chatTypeDescription: 'API 요청 본문을 정리하는 템플릿',
|
||||
chatTypeIsTemplate: true,
|
||||
},
|
||||
'아이패드 말풍선 폰트 조금 줄여줘',
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldUseTemplateMacroReply(
|
||||
{
|
||||
pageId: null,
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://test.sm-home.cloud/chat/live',
|
||||
chatTypeLabel: '일반 요청',
|
||||
chatTypeDescription: '일반 요청',
|
||||
chatTypeIsTemplate: false,
|
||||
},
|
||||
'템플릿 예시 보여줘',
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('fitActivityLogLines keeps modest activity history instead of trimming at 12 lines', () => {
|
||||
const lines = Array.from({ length: 13 }, (_, index) => `# 진행: step ${index + 1}`);
|
||||
|
||||
assert.deepEqual(fitActivityLogLines(lines), lines);
|
||||
});
|
||||
|
||||
test('fitActivityLogLines keeps full activity history when it is within the configured limits', () => {
|
||||
const lines = Array.from({ length: 80 }, (_, index) => `# 진행: step ${index + 1}`);
|
||||
const fitted = fitActivityLogLines(lines);
|
||||
|
||||
assert.equal(fitted.length, 80);
|
||||
assert.equal(fitted[0], '# 진행: step 1');
|
||||
assert.equal(fitted.at(-1), '# 진행: step 80');
|
||||
});
|
||||
|
||||
test('createActivityLogMessage keeps fitted activity history instead of the latest line only', () => {
|
||||
const lines = ['# 상태: 요청을 처리합니다.', '# 진행: 분석 중입니다.', '# 상태: 응답 생성이 완료되었습니다.'];
|
||||
const message = createActivityLogMessage('req-activity', lines);
|
||||
|
||||
assert.ok(message);
|
||||
assert.equal(
|
||||
message?.text,
|
||||
'[[activity-log]]\n# 상태: 요청을 처리합니다.\n\n# 진행: 분석 중입니다.\n\n# 상태: 응답 생성이 완료되었습니다.',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveResponseTimestamp moves fast replies behind the request second', () => {
|
||||
assert.equal(resolveResponseTimestamp(Date.UTC(2026, 3, 16, 0, 0, 0), Date.UTC(2026, 3, 16, 0, 0, 0, 250)), '2026-04-16 09:00:01');
|
||||
});
|
||||
|
||||
test('resolveResponseTimestamp keeps the real time when reply is already later', () => {
|
||||
assert.equal(resolveResponseTimestamp(Date.UTC(2026, 3, 16, 0, 0, 0), Date.UTC(2026, 3, 16, 0, 0, 5)), '2026-04-16 09:00:05');
|
||||
});
|
||||
|
||||
test('extractDiffCodeBlocks collects fenced diff bodies', () => {
|
||||
const output = ['설명', '', '```diff', 'diff --git a/a.ts b/a.ts', '+hello', '```', '', '마무리'].join('\n');
|
||||
|
||||
assert.deepEqual(extractDiffCodeBlocks(output), ['diff --git a/a.ts b/a.ts\n+hello']);
|
||||
});
|
||||
|
||||
test('rewriteCodexOutputWithChatResources stages diff blocks as chat resources', async () => {
|
||||
const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-'));
|
||||
await mkdir(path.join(repoPath, 'public'), { recursive: true });
|
||||
|
||||
const output = ['변경사항입니다.', '', '```diff', 'diff --git a/src/a.ts b/src/a.ts', '+hello', '```'].join('\n');
|
||||
const rewritten = await rewriteCodexOutputWithChatResources(output, repoPath, 'chat-room');
|
||||
const expectedUrl = '/api/chat/resources/.codex_chat/chat-room/resource/_generated/response.diff';
|
||||
const savedDiffPath = path.join(
|
||||
repoPath,
|
||||
'public',
|
||||
'.codex_chat',
|
||||
'chat-room',
|
||||
'resource',
|
||||
'_generated',
|
||||
'response.diff',
|
||||
);
|
||||
|
||||
assert.match(rewritten, new RegExp(`${expectedUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'));
|
||||
assert.equal(await readFile(savedDiffPath, 'utf8'), 'diff --git a/src/a.ts b/src/a.ts\n+hello\n');
|
||||
});
|
||||
|
||||
test('rewriteCodexOutputWithChatResources keeps existing public chat resource paths stable', async () => {
|
||||
const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-'));
|
||||
const originalPath = path.join(repoPath, 'src', 'app', 'main', 'MainChatPanel.tsx');
|
||||
const stagedPath = path.join(
|
||||
repoPath,
|
||||
'public',
|
||||
'.codex_chat',
|
||||
'chat-room',
|
||||
'resource',
|
||||
'src',
|
||||
'app',
|
||||
'main',
|
||||
'MainChatPanel.tsx',
|
||||
);
|
||||
|
||||
await mkdir(path.dirname(originalPath), { recursive: true });
|
||||
await mkdir(path.dirname(stagedPath), { recursive: true });
|
||||
await writeFile(originalPath, 'export const value = 1;\n', 'utf8');
|
||||
await writeFile(stagedPath, 'export const value = 1;\n', 'utf8');
|
||||
|
||||
const output =
|
||||
'리소스 경로는 public/.codex_chat/chat-room/resource/src/app/main/MainChatPanel.tsx 입니다.';
|
||||
const rewritten = await rewriteCodexOutputWithChatResources(output, repoPath, 'chat-room');
|
||||
|
||||
assert.match(rewritten, /\/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/src\/app\/main\/MainChatPanel\.tsx/);
|
||||
assert.doesNotMatch(rewritten, /resource\/public\/\.codex_chat/);
|
||||
assert.equal(await readFile(stagedPath, 'utf8'), 'export const value = 1;\n');
|
||||
});
|
||||
|
||||
test('rewriteCodexOutputWithChatResources stages repo root files linked with a leading slash', async () => {
|
||||
const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-'));
|
||||
const originalPath = path.join(repoPath, 'docker-compose.yml');
|
||||
const stagedPath = path.join(
|
||||
repoPath,
|
||||
'public',
|
||||
'.codex_chat',
|
||||
'chat-room',
|
||||
'resource',
|
||||
'docker-compose.yml',
|
||||
);
|
||||
|
||||
await mkdir(path.dirname(originalPath), { recursive: true });
|
||||
await writeFile(originalPath, 'services:\n app:\n image: node:22\n', 'utf8');
|
||||
|
||||
const output = '파일은 /docker-compose.yml 에 있습니다.';
|
||||
const rewritten = await rewriteCodexOutputWithChatResources(output, repoPath, 'chat-room');
|
||||
|
||||
assert.match(rewritten, /\/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/docker-compose\.yml/);
|
||||
assert.equal(await readFile(stagedPath, 'utf8'), 'services:\n app:\n image: node:22\n');
|
||||
});
|
||||
|
||||
test('rewriteCodexOutputWithChatResources prefers absolute path replacements before nested relative paths', async () => {
|
||||
const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-'));
|
||||
const originalPath = path.join(repoPath, 'etc', 'servers', 'work-server', 'package.json');
|
||||
|
||||
await mkdir(path.dirname(originalPath), { recursive: true });
|
||||
await mkdir(path.join(repoPath, 'public'), { recursive: true });
|
||||
await writeFile(originalPath, '{\n "name": "work-server"\n}\n', 'utf8');
|
||||
|
||||
const output =
|
||||
'변경 파일: [/api/chat/resources/.codex_chat/chat-room/resource/etc/servers/work-server/package.json](' +
|
||||
`${originalPath})`;
|
||||
const rewritten = await rewriteCodexOutputWithChatResources(output, repoPath, 'chat-room');
|
||||
|
||||
assert.match(
|
||||
rewritten,
|
||||
/\[\/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/etc\/servers\/work-server\/package\.json\]\(\/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/etc\/servers\/work-server\/package\.json\)/,
|
||||
);
|
||||
assert.doesNotMatch(rewritten, /\/home\/.+\/api\/chat\/resources/);
|
||||
});
|
||||
|
||||
test('validateAgenticCodexRuntime explains missing runtime paths clearly', async () => {
|
||||
const originalRunnerUrl = env.SERVER_COMMAND_RUNNER_URL;
|
||||
const originalFetch = globalThis.fetch;
|
||||
env.SERVER_COMMAND_RUNNER_URL = 'http://127.0.0.1:3211/health';
|
||||
globalThis.fetch = (async () => {
|
||||
throw new Error('connect ECONNREFUSED');
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
validateAgenticCodexRuntime('/tmp/chat-missing-repo-path', '/tmp/chat-missing-codex-bin'),
|
||||
/채팅 실행 환경이 준비되지 않았습니다\..*PLAN_MAIN_PROJECT_REPO_PATH.*SERVER_COMMAND_RUNNER_URL/s,
|
||||
);
|
||||
} finally {
|
||||
env.SERVER_COMMAND_RUNNER_URL = originalRunnerUrl;
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('validateAgenticCodexRuntime accepts reachable command-runner api', async () => {
|
||||
const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-repo-'));
|
||||
const originalRunnerUrl = env.SERVER_COMMAND_RUNNER_URL;
|
||||
const originalFetch = globalThis.fetch;
|
||||
env.SERVER_COMMAND_RUNNER_URL = 'http://127.0.0.1:3211/health';
|
||||
globalThis.fetch = (async () => new Response(JSON.stringify({ ok: true }), { status: 200 })) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
await assert.doesNotReject(validateAgenticCodexRuntime(repoPath, 'codex'));
|
||||
} finally {
|
||||
env.SERVER_COMMAND_RUNNER_URL = originalRunnerUrl;
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user