Initial import

This commit is contained in:
how2ice
2026-04-21 03:33:23 +09:00
commit 9e4b70f1f1
495 changed files with 94680 additions and 0 deletions

View 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;
}
});