Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 additions & 0 deletions .github/scripts/__tests__/langsmith-tracer.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import {
createLangSmithTracer,
resolveLangSmithTraceConfig,
} from '../langsmith-tracer.mjs';

function createClientRecorder(options = {}) {
const createCalls = [];
const updateCalls = [];
const createErrorMessage = options.createErrorMessage;
const updateErrorMessage = options.updateErrorMessage;

const client = {
async createRun(payload) {
createCalls.push(payload);

if (typeof createErrorMessage === 'string') {
throw new Error(createErrorMessage);
}
},
async updateRun(runId, payload) {
updateCalls.push({
runId,
payload,
});

if (typeof updateErrorMessage === 'string') {
throw new Error(updateErrorMessage);
}
},
};

return {
client,
createCalls,
updateCalls,
};
}

test('LANGSMITH_TRACING=false 이면 tracing 설정이 비활성화된다', () => {
const config = resolveLangSmithTraceConfig({
LANGSMITH_TRACING: 'false',
LANGSMITH_API_KEY: 'lsv2_xxx',
});

assert.equal(config.enabled, false);
assert.equal(config.reason, 'langsmith-tracing-disabled');
});

test('API KEY가 없으면 tracing 설정이 비활성화된다', () => {
const config = resolveLangSmithTraceConfig({
LANGSMITH_TRACING: 'true',
});

assert.equal(config.enabled, false);
assert.equal(config.reason, 'langsmith-api-key-missing');
});

test('설정값이 없으면 endpoint/project 기본값을 사용한다', () => {
const config = resolveLangSmithTraceConfig({
LANGSMITH_API_KEY: 'lsv2_xxx',
});

assert.equal(config.enabled, true);
assert.equal(config.endpoint, 'https://api.smith.langchain.com');
assert.equal(config.projectName, 'caquick-pr-ai-description');
assert.equal(config.workspaceId, null);
});

test('withRun 성공 시 SDK createRun/updateRun이 호출된다', async () => {
const { client, createCalls, updateCalls } = createClientRecorder();
const tracer = createLangSmithTracer({
env: {
LANGSMITH_API_KEY: 'lsv2_xxx',
LANGSMITH_PROJECT: 'caquick-ci',
},
client,
});

const result = await tracer.withRun(
{
name: 'openai-summary',
runType: 'llm',
inputs: { model: 'gpt-4.1-mini' },
mapOutput: (value) => ({
title: value.title,
}),
},
async () => ({
title: '요약 제목',
longText: '생략',
}),
);

assert.equal(result.title, '요약 제목');
assert.equal(createCalls.length, 1);
assert.equal(createCalls[0].project_name, 'caquick-ci');
assert.equal(createCalls[0].run_type, 'llm');

const runId = createCalls[0].id;
assert.ok(typeof runId === 'string' && runId.length > 0);
assert.equal(updateCalls.length, 1);
assert.equal(updateCalls[0].runId, runId);
assert.equal(updateCalls[0].payload.outputs.title, '요약 제목');
});

test('withRun 실패 시 에러를 PATCH로 기록하고 예외를 다시 던진다', async () => {
const { client, createCalls, updateCalls } = createClientRecorder();
const tracer = createLangSmithTracer({
env: {
LANGSMITH_API_KEY: 'lsv2_xxx',
},
client,
});

await assert.rejects(
() =>
tracer.withRun(
{
name: 'fail-step',
runType: 'tool',
inputs: { stage: 'patch-pr' },
},
async () => {
throw new Error('intentional-failure');
},
),
/intentional-failure/,
);

assert.equal(createCalls.length, 1);
assert.equal(updateCalls.length, 1);
assert.equal(updateCalls[0].payload.outputs.constructor, Object);
assert.match(updateCalls[0].payload.error, /intentional-failure/);
});

test('workspace id는 SDK Client 초기화 옵션으로 전달된다', () => {
let capturedClientConfig = null;

createLangSmithTracer({
env: {
LANGSMITH_API_KEY: 'lsv2_xxx',
LANGSMITH_WORKSPACE_ID: 'workspace-123',
LANGSMITH_ENDPOINT: 'https://api.eu.smith.langchain.com',
},
clientFactory: (clientConfig) => {
capturedClientConfig = clientConfig;
return {
async createRun() {},
async updateRun() {},
};
},
});

assert.ok(capturedClientConfig);
assert.equal(capturedClientConfig.workspaceId, 'workspace-123');
assert.equal(capturedClientConfig.apiUrl, 'https://api.eu.smith.langchain.com');
assert.equal(capturedClientConfig.autoBatchTracing, false);
});

test('SDK 호출 실패는 경고 로그만 남기고 작업을 중단하지 않는다', async () => {
const { client, createCalls, updateCalls } = createClientRecorder({
createErrorMessage: 'create-failed',
updateErrorMessage: 'update-failed',
});
const logs = [];
const tracer = createLangSmithTracer({
env: {
LANGSMITH_API_KEY: 'lsv2_xxx',
},
client,
logger: (level, message, payload) => {
logs.push({ level, message, payload });
},
});

const result = await tracer.withRun(
{
name: 'resilient-run',
runType: 'chain',
},
async () => ({
ok: true,
}),
);

assert.equal(result.ok, true);
assert.equal(createCalls.length, 1);
assert.equal(updateCalls.length, 1);
assert.ok(logs.some((entry) => entry.level === 'warn'));
});
187 changes: 187 additions & 0 deletions .github/scripts/__tests__/pr-ai-description-lib.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import {
buildExcludeGlobs,
buildLimitedDiff,
decideCompareFallback,
filterDiffFiles,
filterKnownLabels,
renderSummaryBlock,
upsertSummaryBlock,
validateAiSummaryJson,
} from '../pr-ai-description-lib.mjs';

test('기본 제외 패턴과 추가 패턴이 파일 필터링에 적용된다', () => {
const files = [
{ filename: 'yarn.lock', patch: '@@ -1 +1 @@' },
{ filename: 'src/main.ts', patch: '@@ -1 +1 @@' },
{ filename: 'dist/main.js', patch: '@@ -1 +1 @@' },
{ filename: 'snapshots/user.snap', patch: '@@ -1 +1 @@' },
{ filename: 'logs/error.log', patch: '@@ -1 +1 @@' },
];

const excludeGlobs = buildExcludeGlobs('logs/**');
const result = filterDiffFiles(files, excludeGlobs);

assert.equal(result.included.length, 1);
assert.equal(result.included[0].filename, 'src/main.ts');
assert.equal(result.excludedFilesCount, 4);
});

test('Diff 절단 시 메타가 계산되고 최종 bytes가 한도를 넘지 않는다', () => {
const entries = [
{ filename: 'src/a.ts', status: 'modified', patch: 'line\n'.repeat(15) },
{ filename: 'src/b.ts', status: 'modified', patch: 'line\n'.repeat(15) },
{ filename: 'src/c.ts', status: 'modified', patch: 'line\n'.repeat(15) },
];

const limited = buildLimitedDiff(entries, 230);

assert.equal(limited.meta.totalFiles, 3);
assert.equal(limited.meta.truncated, true);
assert.ok(limited.meta.includedFiles < 3);
assert.ok(limited.meta.finalBytes <= 230);
assert.match(limited.diffText, /# diff-truncation-meta/);
});

test('JSON schema 검증 성공/실패를 구분한다', () => {
const validPayload = {
title: '사용자 조회 API 개선',
summary: '응답 필드와 예외 처리를 정리했습니다.',
changes: [
{
area: 'user',
description: '조회 조건 검증 로직 추가',
importance: 'medium',
},
],
impact: {
api: 'medium',
db: 'low',
security: 'low',
performance: 'low',
operations: 'low',
tests: 'medium',
},
checklist: ['resolver 통합 테스트 확인'],
risks: ['기존 캐시 키와 충돌 가능성 점검 필요'],
labels: ['backend', 'api'],
};

const validated = validateAiSummaryJson(validPayload);
assert.equal(validated.title, validPayload.title);
assert.deepEqual(validated.labels, ['backend', 'api']);

assert.throws(
() =>
validateAiSummaryJson({
...validPayload,
impact: {
...validPayload.impact,
api: 'critical',
},
}),
/invalid-impact-api-value/,
);

assert.throws(
() =>
validateAiSummaryJson({
...validPayload,
unknown: 'x',
}),
/invalid-root-additional-property/,
);
});

test('마커 블록이 있으면 교체하고 없으면 하단에 추가한다', () => {
const summary = {
title: 'AI 제목',
summary: '요약',
changes: [{ area: 'auth', description: '가드 수정', importance: 'high' }],
impact: {
api: 'low',
db: 'low',
security: 'medium',
performance: 'low',
operations: 'low',
tests: 'medium',
},
checklist: ['테스트 실행'],
risks: ['권한 정책 확인'],
labels: [],
};

const block = renderSummaryBlock(summary, {
diffSource: 'compare',
finalBytes: 120,
excludedFilesCount: 1,
truncated: false,
assigneesAdded: ['chanwoo7'],
labelsAdded: ['backend'],
unknownLabelsIgnoredCount: 0,
});

const bodyWithoutMarker = '기존 본문';
const appended = upsertSummaryBlock(bodyWithoutMarker, block);
assert.match(appended, /기존 본문/);
assert.match(appended, /<!-- pr-ai-summary:start -->/);

const bodyWithMarker = [
'앞부분',
'<!-- pr-ai-summary:start -->',
'old',
'<!-- pr-ai-summary:end -->',
'뒷부분',
].join('\n');

const replaced = upsertSummaryBlock(bodyWithMarker, block);
assert.match(replaced, /앞부분/);
assert.match(replaced, /뒷부분/);
assert.equal((replaced.match(/<!-- pr-ai-summary:start -->/g) ?? []).length, 1);
assert.equal((replaced.match(/<!-- pr-ai-summary:end -->/g) ?? []).length, 1);
assert.doesNotMatch(replaced, /\nold\n/);
});

test('Compare API fallback 조건에서 patch 누락 1개만 있어도 fallback 된다', () => {
const files = [
{ filename: 'src/a.ts', status: 'modified', patch: '@@ -1 +1 @@' },
{ filename: 'src/b.ts', status: 'modified' },
];

const decision = decideCompareFallback({
files,
excludeGlobs: buildExcludeGlobs(''),
maxFiles: 10,
});

assert.equal(decision.useFallback, true);
assert.equal(decision.reason, 'compare-missing-patch');
});

test('Compare API 성공 조건이면 fallback 없이 진행한다', () => {
const files = [
{ filename: 'src/a.ts', status: 'modified', patch: '@@ -1 +1 @@' },
{ filename: 'src/b.ts', status: 'added', patch: '@@ -0,0 +1 @@' },
];

const decision = decideCompareFallback({
files,
excludeGlobs: buildExcludeGlobs(''),
maxFiles: 10,
});

assert.equal(decision.useFallback, false);
assert.equal(decision.included.length, 2);
});

test('레포 라벨 목록 기준으로 unknown 라벨을 제거한다', () => {
const aiLabels = ['Bug', 'invalid', 'db', 'BUG', ''];
const repoLabels = ['bug', 'feature', 'db'];

const result = filterKnownLabels(aiLabels, repoLabels);

assert.deepEqual(result.applicableLabels, ['bug', 'db']);
assert.equal(result.unknownLabelsIgnoredCount, 2);
});
Loading