feat: [flow-tester] API 로그 캡처 및 UI 개선

- ApiLogCapturer 추가: 플로우 실행 중 API 로그 캡처
- resolveBaseUrl() 추가: .env 환경변수 기반 baseUrl 지원
- 실행 상세 페이지: 스텝별 접기/펼치기 기능 (성공=접힘, 실패=펼침)
- JSON 가이드 및 예제 플로우 최신화
- AI 프롬프트 템플릿 업데이트
- bindExpectVariables() 추가: expect jsonPath 값에 변수 바인딩 적용
- areNumericEqual() 추가: 숫자 타입 유연 비교 ("2" == 2)
This commit is contained in:
2025-12-04 15:30:04 +09:00
parent 20cfa01579
commit fe10cae06c
9 changed files with 709 additions and 143 deletions

View File

@@ -22,7 +22,7 @@ class FlowTesterController extends Controller
public function index(): View
{
$flows = AdminApiFlow::with(['runs' => fn ($q) => $q->latest()->limit(1)])
->orderByDesc('updated_at')
->orderByDesc('created_at')
->paginate(20);
return view('dev-tools.flow-tester.index', compact('flows'));
@@ -231,6 +231,7 @@ public function run(int $id)
'failed_step' => $result['failedStep'],
'execution_log' => $result['executionLog'],
'error_message' => $result['errorMessage'],
'api_logs' => $result['apiLogs'] ?? [],
]);
return response()->json([

View File

@@ -53,6 +53,7 @@ class AdminApiFlowRun extends Model
'execution_log',
'input_variables',
'error_message',
'api_logs',
'executed_by',
];
@@ -67,6 +68,7 @@ class AdminApiFlowRun extends Model
'failed_step' => 'integer',
'execution_log' => 'array',
'input_variables' => 'array',
'api_logs' => 'array',
'executed_by' => 'integer',
];
@@ -171,4 +173,33 @@ public function getStatusColorAttribute(): string
default => 'bg-gray-100 text-gray-600',
};
}
/**
* API 로그 개수 반환
*/
public function getApiLogCountAttribute(): int
{
return is_array($this->api_logs) ? count($this->api_logs) : 0;
}
/**
* API 로그에 오류가 있는지 확인
*/
public function hasApiErrors(): bool
{
if (! is_array($this->api_logs)) {
return false;
}
foreach ($this->api_logs as $log) {
if (isset($log['type']) && $log['type'] === 'response') {
$status = $log['status'] ?? 0;
if ($status >= 400) {
return true;
}
}
}
return false;
}
}

View File

@@ -0,0 +1,171 @@
<?php
namespace App\Services\FlowTester;
/**
* API 로그 캡처 서비스
*
* API 서버의 laravel.log에서 Request/Response 로그를 캡처합니다.
* 플로우 실행 시작/종료 시점의 파일 오프셋을 이용하여
* 해당 실행 중에 발생한 로그만 추출합니다.
*/
class ApiLogCapturer
{
private string $logPath;
private int $startOffset = 0;
public function __construct()
{
// API 로그 파일 경로 (Docker/로컬 환경 모두 지원)
$this->logPath = $this->resolveLogPath();
}
/**
* 캡처 시작 (현재 로그 파일 위치 기록)
*/
public function start(): void
{
if (file_exists($this->logPath)) {
$this->startOffset = filesize($this->logPath);
} else {
$this->startOffset = 0;
}
}
/**
* 캡처 종료 및 로그 추출
*
* @return array 파싱된 API 로그 배열
*/
public function capture(): array
{
if (! file_exists($this->logPath)) {
return [];
}
$currentSize = filesize($this->logPath);
// 새로운 로그가 없으면 빈 배열 반환
if ($currentSize <= $this->startOffset) {
return [];
}
// 새로 추가된 로그 읽기
$handle = fopen($this->logPath, 'r');
if (! $handle) {
return [];
}
fseek($handle, $this->startOffset);
$newLogs = fread($handle, $currentSize - $this->startOffset);
fclose($handle);
// Request/Response 로그만 파싱
return $this->parseLogs($newLogs);
}
/**
* API 로그 파일 경로 결정
*/
private function resolveLogPath(): string
{
// 1. 환경 변수로 지정된 경로
$envPath = env('API_LOG_PATH');
if ($envPath && file_exists($envPath)) {
return $envPath;
}
// 2. 로컬 개발 환경 (상대 경로)
$localPath = base_path('../api/storage/logs/laravel.log');
if (file_exists($localPath)) {
return $localPath;
}
// 3. Docker 환경 (절대 경로)
$dockerPath = '/var/www/api/storage/logs/laravel.log';
if (file_exists($dockerPath)) {
return $dockerPath;
}
// 4. 기본값 (로컬 경로)
return $localPath;
}
/**
* 로그 텍스트 파싱
*
* Laravel 로그 형식:
* [2025-12-04 12:30:53] local.INFO: API Request {...}
* [2025-12-04 12:30:53] local.INFO: API Response {...}
*/
private function parseLogs(string $rawLogs): array
{
$logs = [];
$pattern = '/\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] \w+\.\w+: (API Request|API Response) (\{.+?\})(?=\s*\[\d{4}|\s*$)/s';
if (preg_match_all($pattern, $rawLogs, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$timestamp = $match[1];
$type = $match[2] === 'API Request' ? 'request' : 'response';
$jsonStr = $match[3];
// JSON 파싱
$data = json_decode($jsonStr, true);
if ($data === null) {
continue;
}
$log = [
'timestamp' => $timestamp,
'type' => $type,
];
if ($type === 'request') {
$log['method'] = $data['method'] ?? '';
$log['uri'] = $data['uri'] ?? '';
$log['input'] = $data['input'] ?? [];
$log['ip'] = $data['ip'] ?? '';
} else {
$log['uri'] = $data['uri'] ?? '';
$log['status'] = $data['status'] ?? 0;
// content는 JSON 문자열이므로 파싱
$content = $data['content'] ?? '';
if (is_string($content)) {
$parsedContent = json_decode($content, true);
if ($parsedContent !== null) {
$log['success'] = $parsedContent['success'] ?? null;
$log['message'] = $parsedContent['message'] ?? '';
// 에러 정보가 있으면 포함
if (isset($parsedContent['error'])) {
$log['error'] = $parsedContent['error'];
}
}
}
}
$logs[] = $log;
}
}
return $logs;
}
/**
* 로그 파일 경로 반환 (디버깅용)
*/
public function getLogPath(): string
{
return $this->logPath;
}
/**
* 로그 파일 존재 여부 확인
*/
public function isAvailable(): bool
{
return file_exists($this->logPath) && is_readable($this->logPath);
}
}

View File

@@ -26,6 +26,8 @@ class FlowExecutor
private HttpClient $httpClient;
private ApiLogCapturer $logCapturer;
/**
* 실행 로그
*/
@@ -48,16 +50,23 @@ class FlowExecutor
*/
private array $stepSuccessMap = [];
/**
* 캡처된 API 로그
*/
private array $apiLogs = [];
public function __construct(
?VariableBinder $binder = null,
?DependencyResolver $resolver = null,
?ResponseValidator $validator = null,
?HttpClient $httpClient = null
?HttpClient $httpClient = null,
?ApiLogCapturer $logCapturer = null
) {
$this->binder = $binder ?? new VariableBinder;
$this->resolver = $resolver ?? new DependencyResolver;
$this->validator = $validator ?? new ResponseValidator;
$this->httpClient = $httpClient ?? new HttpClient;
$this->logCapturer = $logCapturer ?? new ApiLogCapturer;
}
/**
@@ -72,6 +81,9 @@ public function execute(array $flowDefinition, array $inputVariables = []): arra
$startTime = microtime(true);
$this->reset();
// API 로그 캡처 시작
$this->logCapturer->start();
try {
// 1. 플로우 정의 검증
$this->validateFlowDefinition($flowDefinition);
@@ -242,6 +254,10 @@ private function executeStep(array $step): array
if (empty($expect) || ! isset($expect['status'])) {
$expect['status'] = [200, 201, 204];
}
// expect 값에도 변수 바인딩 적용 (jsonPath 값의 {{변수}} 치환)
$expect = $this->bindExpectVariables($expect);
$validation = $this->validator->validate($response, $expect);
// 6. 변수 추출
@@ -317,9 +333,10 @@ private function validateFlowDefinition(array $definition): void
*/
private function applyConfig(array $config): void
{
// Base URL - Docker 환경 자동 변환
if (isset($config['baseUrl'])) {
$baseUrl = $config['baseUrl'];
// Base URL 결정 - JSON에 있으면 사용, 없으면 .env에서
$baseUrl = $this->resolveBaseUrl($config['baseUrl'] ?? null);
if ($baseUrl) {
// Docker 환경에서 외부 URL을 내부 URL로 변환
if ($this->isDockerEnvironment()) {
@@ -395,6 +412,46 @@ private function getDefaultBearerToken(): ?string
return env('FLOW_TESTER_API_TOKEN');
}
/**
* Base URL 결정
*
* 우선순위:
* 1. JSON에 완전한 URL (도메인 포함) → 그대로 사용
* 2. JSON에 경로만 있거나 없음 → .env의 FLOW_TESTER_API_BASE_URL + 경로
*
* @param string|null $configBaseUrl JSON config의 baseUrl
* @return string|null 최종 Base URL
*/
private function resolveBaseUrl(?string $configBaseUrl): ?string
{
// 환경변수에서 기본 도메인 가져오기
$envBaseUrl = env('FLOW_TESTER_API_BASE_URL');
// JSON에 baseUrl이 없는 경우
if (empty($configBaseUrl)) {
return $envBaseUrl ?: null;
}
// JSON에 완전한 URL이 있는지 확인 (http:// 또는 https://로 시작)
if (preg_match('#^https?://#', $configBaseUrl)) {
// 완전한 URL이면 그대로 사용
return $configBaseUrl;
}
// 상대 경로만 있는 경우 (.env 도메인 + 상대 경로)
if ($envBaseUrl) {
// .env URL 끝의 슬래시 제거
$envBaseUrl = rtrim($envBaseUrl, '/');
// 상대 경로 시작의 슬래시 보장
$configBaseUrl = '/'.ltrim($configBaseUrl, '/');
return $envBaseUrl.$configBaseUrl;
}
// .env도 없고 상대 경로만 있으면 그대로 반환 (실패할 수 있음)
return $configBaseUrl;
}
/**
* Docker 환경인지 확인
*/
@@ -449,6 +506,9 @@ private function buildResult(
): array {
$duration = (int) ((microtime(true) - $startTime) * 1000);
// API 로그 캡처
$this->apiLogs = $this->logCapturer->capture();
return [
'status' => $status,
'duration' => $duration,
@@ -457,6 +517,7 @@ private function buildResult(
'failedStep' => $failedStep,
'errorMessage' => $errorMessage,
'executionLog' => $this->executionLog,
'apiLogs' => $this->apiLogs,
'startedAt' => date('Y-m-d H:i:s', (int) $startTime),
'completedAt' => date('Y-m-d H:i:s'),
];
@@ -472,6 +533,7 @@ private function reset(): void
$this->completedSteps = 0;
$this->totalSteps = 0;
$this->stepSuccessMap = [];
$this->apiLogs = [];
$this->binder->reset();
}
@@ -560,6 +622,27 @@ private function buildResultReason(bool $success, array $expect, int $actualStat
return $reason;
}
/**
* expect 값에 변수 바인딩 적용
*
* jsonPath 값들의 {{변수}} 플레이스홀더를 치환합니다.
* 예: "$.data.client_code": "{{test_client_code}}" → "TEST_CLIENT_123"
*/
private function bindExpectVariables(array $expect): array
{
// jsonPath 값들에 변수 바인딩 적용
if (isset($expect['jsonPath']) && is_array($expect['jsonPath'])) {
foreach ($expect['jsonPath'] as $path => $expected) {
// 문자열이고 {{}} 플레이스홀더가 있는 경우만 바인딩
if (is_string($expected) && str_contains($expected, '{{')) {
$expect['jsonPath'][$path] = $this->binder->bind($expected);
}
}
}
return $expect;
}
/**
* assertions 배열 형식을 expect 객체 형식으로 변환
*

View File

@@ -91,6 +91,12 @@ private function validateValue(mixed $actual, mixed $expected, string $path): ?s
{
// 직접 값 비교 (연산자가 아닌 경우)
if (! is_string($expected) || ! str_starts_with($expected, '@')) {
// 숫자 비교: 둘 다 숫자(또는 숫자 문자열)인 경우 타입 무관하게 비교
// 예: "2" == 2, "123" == 123
if ($this->areNumericEqual($actual, $expected)) {
return null;
}
if ($actual !== $expected) {
return sprintf(
'Path %s: expected %s, got %s',
@@ -266,6 +272,30 @@ private function formatList(array $items): string
return '['.implode(', ', $items).']';
}
/**
* 숫자 값 비교 (타입 무관)
*
* 둘 다 숫자(또는 숫자 문자열)인 경우 값이 같은지 비교합니다.
* 예: "2" == 2 → true, "123" == 123 → true
*/
private function areNumericEqual(mixed $actual, mixed $expected): bool
{
// 둘 다 숫자 또는 숫자 문자열인 경우에만 비교
if (is_numeric($actual) && is_numeric($expected)) {
// 정수 비교가 가능한 경우 정수로 비교
if (is_int($actual) || is_int($expected) ||
(is_string($actual) && ctype_digit(ltrim($actual, '-'))) ||
(is_string($expected) && ctype_digit(ltrim($expected, '-')))) {
return (int) $actual === (int) $expected;
}
// 그 외의 경우 float로 비교
return (float) $actual === (float) $expected;
}
return false;
}
/**
* 응답에서 값 추출 (extract 처리)
*

View File

@@ -78,11 +78,13 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap w-12">#</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">이름</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">카테고리</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">스텝</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">최근 실행</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">상태</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">생성일</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">액션</th>
</tr>
</thead>
@@ -94,6 +96,9 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
@endphp
{{-- 메인 row --}}
<tr class="hover:bg-gray-50 cursor-pointer flow-row" data-flow-id="{{ $flow->id }}" onclick="toggleFlowDetail({{ $flow->id }}, event)">
<td class="px-3 py-4 text-center text-sm text-gray-500">
{{ $flows->firstItem() + $loop->index }}
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-gray-400 transition-transform flow-chevron" id="chevron-{{ $flow->id }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -107,24 +112,24 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
</div>
</div>
</td>
<td class="px-6 py-4">
<td class="px-6 py-4 whitespace-nowrap">
@if($flow->category)
<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-700 rounded">{{ $flow->category }}</span>
@else
<span class="text-gray-400">-</span>
@endif
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<td class="px-6 py-4 text-sm text-gray-500 whitespace-nowrap">
{{ $flow->step_count }}
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<td class="px-6 py-4 text-sm text-gray-500 whitespace-nowrap">
@if($latestRun)
{{ $latestRun->created_at->diffForHumans() }}
@else
<span class="text-gray-400">-</span>
@endif
</td>
<td class="px-6 py-4">
<td class="px-6 py-4 whitespace-nowrap">
@if($latestRun)
<span class="px-2 py-1 text-xs font-medium rounded {{ $latestRun->status_color }}">
{{ $latestRun->status_label }}
@@ -133,8 +138,11 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-500 rounded">대기</span>
@endif
</td>
<td class="px-6 py-4 text-right" onclick="event.stopPropagation()">
<div class="flex justify-end gap-2">
<td class="px-6 py-4 text-sm text-gray-500 whitespace-nowrap">
{{ $flow->created_at->format('y.m.d') }}
</td>
<td class="px-4 py-4 text-right" onclick="event.stopPropagation()">
<div class="flex justify-end gap-1">
<!-- 실행 버튼 -->
<button onclick="runFlow({{ $flow->id }})"
class="p-2 text-green-600 hover:bg-green-50 rounded-lg transition"
@@ -193,7 +201,7 @@ class="p-2 text-red-600 hover:bg-red-50 rounded-lg transition"
}
@endphp
<tr id="flow-detail-{{ $flow->id }}" class="hidden">
<td colspan="6" class="px-6 py-4 bg-gray-50">
<td colspan="8" class="px-6 py-4 bg-gray-50">
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<h4 class="text-sm font-semibold text-gray-700">플로우 스텝 ({{ count($steps) }})</h4>
@@ -555,6 +563,8 @@ function showResultModal(data) {
</div>
` : ''}
${getApiLogSummary(result.apiLogs)}
<div class="flex gap-3">
<button onclick="this.closest('.fixed').remove(); location.reload();"
class="flex-1 px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition">
@@ -571,6 +581,56 @@ class="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-center rou
document.body.appendChild(modal);
}
// API 로그 요약 생성
function getApiLogSummary(apiLogs) {
if (!apiLogs || apiLogs.length === 0) {
return '';
}
// Request/Response 분리 및 에러 카운트
const requests = apiLogs.filter(l => l.type === 'request');
const responses = apiLogs.filter(l => l.type === 'response');
const errors = responses.filter(l => (l.status || 0) >= 400 || l.success === false);
const hasErrors = errors.length > 0;
// 최대 3개만 표시
const displayLogs = apiLogs.slice(0, 6);
const logItems = displayLogs.map(log => {
const isRequest = log.type === 'request';
const isError = !isRequest && ((log.status || 0) >= 400 || log.success === false);
if (isRequest) {
return `<div class="flex items-center gap-2 py-1 text-sm">
<span class="px-1.5 py-0.5 text-xs font-medium bg-blue-100 text-blue-700 rounded">REQ</span>
<code class="text-gray-700 text-xs">${log.method || ''} ${log.uri || ''}</code>
</div>`;
} else {
return `<div class="flex items-center gap-2 py-1 text-sm ${isError ? 'text-red-600' : ''}">
<span class="px-1.5 py-0.5 text-xs font-medium ${isError ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'} rounded">RES</span>
<span class="px-1.5 py-0.5 text-xs font-medium ${isError ? 'bg-red-600' : 'bg-green-600'} text-white rounded">${log.status || '-'}</span>
<code class="text-gray-500 text-xs truncate">${log.uri || ''}</code>
${isError && log.message ? `<span class="text-red-500 text-xs">${log.message}</span>` : ''}
</div>`;
}
}).join('');
const moreCount = apiLogs.length > 6 ? apiLogs.length - 6 : 0;
return `
<div class="mb-4">
<h4 class="text-sm font-medium text-gray-700 mb-2 flex items-center gap-2">
API 로그
<span class="text-xs font-normal text-gray-400">(${responses.length}개 응답)</span>
${hasErrors ? `<span class="px-1.5 py-0.5 text-xs font-medium bg-red-100 text-red-700 rounded">${errors.length} 오류</span>` : ''}
</h4>
<div class="${hasErrors ? 'bg-red-50 border border-red-200' : 'bg-gray-50'} rounded-lg p-3 max-h-32 overflow-y-auto">
${logItems}
${moreCount > 0 ? `<div class="text-xs text-gray-400 mt-1">... ${moreCount} (상세 보기에서 확인)</div>` : ''}
</div>
</div>
`;
}
function confirmDelete(id, name) {
if (!confirm(`"${name}" 플로우를 삭제하시겠습니까?`)) return;

View File

@@ -409,53 +409,87 @@ class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-
]
},
'auth-flow': {
"name": "인증 플로우 테스트",
"description": "로그인, 프로필 조회, 토큰 갱신, 로그아웃 플로우를 테스트합니다.",
"version": "1.0",
"config": {
"baseUrl": "https://sam.kr/api/v1",
"baseUrl": "https://api.sam.kr/api/v1",
"apiKey": "YOUR_API_KEY_HERE",
"timeout": 30000,
"stopOnFailure": true
},
"variables": {
"email": "test@example.com",
"password": "password123"
"user_id": "{{$env.FLOW_TESTER_USER_ID}}",
"user_pwd": "{{$env.FLOW_TESTER_USER_PWD}}"
},
"steps": [
{
"id": "login",
"name": "로그인",
"method": "POST",
"endpoint": "/auth/login",
"body": { "email": "{{variables.email}}", "password": "{{variables.password}}" },
"expect": { "status": [200], "jsonPath": { "$.success": true, "$.data.token": "@isString" } },
"extract": { "accessToken": "$.data.token", "refreshToken": "$.data.refresh_token" }
"endpoint": "/login",
"body": {
"user_id": "{{variables.user_id}}",
"user_pwd": "{{variables.user_pwd}}"
},
"expect": {
"status": [200],
"jsonPath": {
"$.access_token": "@isString"
}
},
"extract": {
"accessToken": "$.access_token",
"refreshToken": "$.refresh_token"
}
},
{
"id": "get_profile",
"name": "프로필 조회",
"dependsOn": ["login"],
"method": "GET",
"endpoint": "/auth/me",
"headers": { "Authorization": "Bearer {{login.accessToken}}" },
"expect": { "status": [200], "jsonPath": { "$.data.email": "{{variables.email}}" } }
"endpoint": "/users/me",
"headers": {
"Authorization": "Bearer {{login.accessToken}}"
},
"expect": {
"status": [200],
"jsonPath": {
"$.success": true
}
}
},
{
"id": "refresh_token",
"name": "토큰 갱신",
"dependsOn": ["get_profile"],
"method": "POST",
"endpoint": "/auth/refresh",
"body": { "refresh_token": "{{login.refreshToken}}" },
"expect": { "status": [200], "jsonPath": { "$.data.token": "@isString" } },
"extract": { "newToken": "$.data.token" }
"endpoint": "/refresh",
"body": {
"refresh_token": "{{login.refreshToken}}"
},
"expect": {
"status": [200],
"jsonPath": {
"$.access_token": "@isString"
}
},
"extract": {
"newToken": "$.access_token"
}
},
{
"id": "logout",
"name": "로그아웃",
"dependsOn": ["refresh_token"],
"method": "POST",
"endpoint": "/auth/logout",
"headers": { "Authorization": "Bearer {{refresh_token.newToken}}" },
"expect": { "status": [200, 204] }
"endpoint": "/logout",
"headers": {
"Authorization": "Bearer {{refresh_token.newToken}}"
},
"expect": {
"status": [200, 204]
}
}
]
},

View File

@@ -53,19 +53,18 @@ class="text-gray-400 hover:text-gray-600 transition-colors">
<div class="prose prose-sm max-w-none">
<h4 class="text-lg font-semibold mb-3">1. 전체 구조</h4>
<pre class="bg-gray-100 p-4 rounded-lg text-xs overflow-x-auto"><code>{
"name": "플로우 이름", // 플로우 제목 (필수)
"description": "플로우 설명", // 상세 설명 (선택)
"version": "1.0",
"config": {
"baseUrl": "https://sam.kr/api/v1",
"baseUrl": "https://api.sam.kr/api/v1",
"apiKey": "@{{$env.API_KEY}}", // API 키 (선택)
"timeout": 30000,
"stopOnFailure": true,
"headers": {
"Accept": "application/json",
"Authorization": "Bearer @{{auth_token}}"
}
"stopOnFailure": true
},
"variables": {
"testPrefix": "TEST_",
"timestamp": "@{{$timestamp}}"
"user_id": "@{{$env.FLOW_TESTER_USER_ID}}", // 환경변수 참조
"user_pwd": "@{{$env.FLOW_TESTER_USER_PWD}}"
},
"steps": [...]
}</code></pre>
@@ -352,32 +351,29 @@ class="absolute top-2 right-2 px-3 py-1 text-xs bg-gray-700 hover:bg-gray-600 te
[테스트하려는 기능/시나리오 설명]
## API 서버 정보
- Base URL: https://sam.kr/api/v1
- 인증: Bearer Token
- Base URL: https://api.sam.kr/api/v1
- API Key: 별도 제공 (config.apiKey에 설정)
- 인증: user_id, user_pwd로 로그인 Bearer Token 사용
## 테스트 플로우
1. [ 번째 단계]
2. [ 번째 단계] - 1번에서 생성된 ID 사용
2. [ 번째 단계] - 1번에서 추출한 사용
3. ...
## API 엔드포인트 참고
[Swagger 문서 URL 또는 API 명세]
## JSON 형식 (반드시 이 형식으로!)
{
"name": "플로우 이름 (50자 이내)",
"description": "플로우 상세 설명",
"version": "1.0",
"meta": {
"name": "플로우 이름 (50자 이내)",
"description": "상세 설명",
"tags": ["category", "integration", "crud"]
},
"config": {
"baseUrl": "https://sam.kr/api/v1",
"baseUrl": "https://api.sam.kr/api/v1",
"apiKey": "YOUR_API_KEY",
"timeout": 30000,
"stopOnFailure": true
},
"variables": {
"testPrefix": "TEST_"
"user_id": "@{{$env.FLOW_TESTER_USER_ID}}",
"user_pwd": "@{{$env.FLOW_TESTER_USER_PWD}}"
},
"steps": [
{
@@ -386,57 +382,69 @@ class="absolute top-2 right-2 px-3 py-1 text-xs bg-gray-700 hover:bg-gray-600 te
"method": "POST",
"endpoint": "/path",
"body": { ... },
"expect": { "status": [200, 201] },
"extract": { "변수명": "$.data.id" }
"expect": {
"status": [200],
"jsonPath": { "$.access_token": "@isString" }
},
"extract": { "token": "$.access_token" }
}
]
}
## 주의사항
- meta.name: 값이 플로우 이름으로 저장됨
- meta.tags[0]: 번째 태그가 카테고리 사용
- meta.description: 값이 설명으로 저장됨
- 스텝 ID는 고유해야
- extract로 추출한 값은 이후 스텝에서 @{{stepId.변수명}}으로 사용</pre>
- name: 최상위 필드로, 플로우 이름으로 저장됨
- description: 최상위 필드로, 설명으 저장
- config.apiKey: API 인증키 설정
- variables에서 @{{$env.XXX}} 형식으로 환경변수 참조 가능
- extract로 추출한 값은 이후 스텝에서 @{{stepId.변수명}}으로 사용
- headers에 Authorization: Bearer @{{login.token}} 형식으로 토큰 전달</pre>
</div>
<h4 class="text-lg font-semibold mt-6 mb-3">실제 예시: 품목관리 API 테스트</h4>
<h4 class="text-lg font-semibold mt-6 mb-3">실제 예시: 인증 플로우 테스트</h4>
<div class="relative">
<button onclick="copyExamplePrompt()"
class="absolute top-2 right-2 px-3 py-1 text-xs bg-gray-700 hover:bg-gray-600 text-white rounded transition-colors z-10">
복사
</button>
<pre id="example-prompt" class="bg-gray-800 text-gray-100 p-4 rounded-lg text-xs overflow-x-auto">품목관리(Item Master) API 통합 테스트 플로우를 만들어줘.
<pre id="example-prompt" class="bg-gray-800 text-gray-100 p-4 rounded-lg text-xs overflow-x-auto">인증 API 테스트 플로우를 만들어줘.
## 테스트 목적
품목관리의 페이지, 섹션, 필드 CRUD 연결/해제 기능 검증
로그인, 프로필 조회, 토큰 갱신, 로그아웃 전체 인증 플로우 검증
## API 서버
- Base URL: https://sam.kr/api/v1
- 인증: Bearer Token
- Base URL: https://api.sam.kr/api/v1
- 인증: user_id/user_pwd access_token/refresh_token
## 테스트 시나리오
1. 페이지 생성 삭제 다시 생성
2. 섹션 독립 생성 삭제 다시 생성
3. 필드 독립 생성 삭제 다시 생성
4. 페이지에 섹션 연결 연결 해제 다시 연결
5. 섹션에 필드 연결 연결 해제
6. 정리: 생성된 리소스 삭제
1. 로그인 access_token, refresh_token 추출
2. 프로필 조회 Authorization 헤더에 access_token 사용
3. 토큰 갱신 refresh_token으로 access_token 발급
4. 로그아웃 access_token으로 로그아웃
## API 엔드포인트
- POST /item-master/pages - 페이지 생성
- DELETE /item-master/pages/{id} - 페이지 삭제
- POST /item-master/sections - 섹션 독립 생성
- POST /item-master/pages/{id}/link-section - 섹션 연결
- POST /item-master/sections/{id}/link-field - 필드 연결
- POST /login - { user_id, user_pwd } { access_token, refresh_token }
- GET /users/me - Authorization: Bearer {token} { success: true }
- POST /refresh - { refresh_token } { access_token }
- POST /logout - Authorization: Bearer {token}
## JSON 형식 (meta 포함 필수!)
- meta.name: "품목관리 API 통합 테스트"
- meta.tags: ["item-master", "integration", "crud"]
- meta.description: "페이지/섹션/필드 CRUD 및 연결 기능 검증"
## JSON 형식
{
"name": "인증 플로우 테스트",
"description": "로그인, 프로필 조회, 토큰 갱신, 로그아웃 플로우를 테스트합니다.",
"version": "1.0",
"config": {
"baseUrl": "https://api.sam.kr/api/v1",
"apiKey": "YOUR_API_KEY",
"timeout": 30000,
"stopOnFailure": true
},
"variables": {
"user_id": "@{{$env.FLOW_TESTER_USER_ID}}",
"user_pwd": "@{{$env.FLOW_TESTER_USER_PWD}}"
}
}
API 응답에서 생성된 ID를 추출해서 다음 단계에서 사용해줘.
테스트 데이터는 "TEST_" 접두사를 붙여줘.</pre>
extract로 token을 추출하고 다음 스텝 headers에서 사용해줘.</pre>
</div>
<div class="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">

View File

@@ -84,80 +84,228 @@ class="p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition">
@endif
</div>
<!-- 실행 로그 -->
<!-- 실행 로그 API 로그 () -->
<div class="lg:col-span-2">
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">실행 로그</h2>
<div class="bg-white rounded-lg shadow-sm" x-data="{ activeTab: 'execution' }">
<!-- 헤더 -->
<div class="flex border-b">
<button @click="activeTab = 'execution'"
:class="activeTab === 'execution' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'"
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors">
실행 로그
@if($run->execution_log)
<span class="ml-1 text-xs text-gray-400">({{ count($run->execution_log) }})</span>
@endif
</button>
<button @click="activeTab = 'api'"
:class="activeTab === 'api' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'"
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors flex items-center gap-2">
API 로그
@if($run->api_logs)
<span class="text-xs text-gray-400">({{ count($run->api_logs) }})</span>
@if($run->hasApiErrors())
<span class="w-2 h-2 bg-red-500 rounded-full"></span>
@endif
@endif
</button>
</div>
@if($run->execution_log)
<div class="space-y-4">
@foreach($run->execution_log as $index => $log)
<div class="border rounded-lg p-4 {{ ($log['success'] ?? false) ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50' }}">
<div class="flex justify-between items-start mb-2">
<div class="flex items-center gap-2">
@if($log['success'] ?? false)
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
<!-- 실행 로그 -->
<div x-show="activeTab === 'execution'" class="p-6">
@if($run->execution_log)
<div class="space-y-3">
@foreach($run->execution_log as $index => $log)
<div x-data="{ expanded: {{ ($log['success'] ?? false) ? 'false' : 'true' }} }"
class="border rounded-lg {{ ($log['success'] ?? false) ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50' }}">
{{-- 헤더 (항상 표시, 클릭으로 토글) --}}
<div @click="expanded = !expanded"
class="flex justify-between items-center p-4 cursor-pointer hover:bg-opacity-80 transition">
<div class="flex items-center gap-2">
{{-- 펼침/접힘 아이콘 --}}
<svg class="w-4 h-4 text-gray-400 transition-transform duration-200"
:class="expanded ? 'rotate-90' : ''"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
@else
<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
@endif
<span class="font-medium">{{ $log['step_id'] ?? 'Step '.($index + 1) }}</span>
<span class="text-gray-500">{{ $log['name'] ?? '' }}</span>
{{-- 성공/실패 아이콘 --}}
@if($log['success'] ?? false)
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
@else
<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
@endif
<span class="font-medium">{{ $log['step_id'] ?? 'Step '.($index + 1) }}</span>
<span class="text-gray-500">{{ $log['name'] ?? '' }}</span>
{{-- 간단한 요약 정보 --}}
@if(isset($log['request']))
<code class="text-xs text-gray-600 bg-white bg-opacity-50 px-2 py-0.5 rounded">
{{ $log['request']['method'] ?? '' }} {{ Str::limit($log['request']['endpoint'] ?? '', 30) }}
</code>
@endif
</div>
<div class="flex items-center gap-3">
@if(isset($log['response']['status']))
<span class="px-2 py-0.5 text-xs font-medium rounded {{ ($log['response']['status'] ?? 0) < 400 ? 'bg-green-600 text-white' : 'bg-red-600 text-white' }}">
{{ $log['response']['status'] }}
</span>
@endif
<span class="text-sm text-gray-500">{{ $log['duration'] ?? '' }}ms</span>
</div>
</div>
{{-- 상세 내용 (펼쳤을 때만 표시) --}}
<div x-show="expanded" x-collapse class="px-4 pb-4 border-t border-gray-200 border-opacity-50">
<div class="pt-3 space-y-3">
{{-- Request 상세 --}}
@if(isset($log['request']))
<div class="text-sm">
<div class="font-medium text-gray-700 mb-1">Request</div>
<div class="bg-white bg-opacity-60 rounded p-2">
<code class="text-gray-800">{{ $log['request']['method'] ?? '' }} {{ $log['request']['endpoint'] ?? '' }}</code>
@if(!empty($log['request']['body']))
<details class="mt-2">
<summary class="cursor-pointer text-xs text-gray-500 hover:text-gray-700">요청 데이터</summary>
<pre class="mt-1 p-2 bg-gray-100 rounded text-xs overflow-x-auto">{{ json_encode($log['request']['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
</details>
@endif
</div>
</div>
@endif
{{-- Response 상세 --}}
@if(isset($log['response']))
<div class="text-sm">
<div class="font-medium text-gray-700 mb-1">Response</div>
<div class="bg-white bg-opacity-60 rounded p-2">
<div class="flex items-center gap-2">
<span class="px-2 py-0.5 text-xs font-medium rounded {{ ($log['response']['status'] ?? 0) < 400 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700' }}">
{{ $log['response']['status'] ?? '-' }}
</span>
@if(isset($log['expect']['status']))
<span class="text-xs text-gray-500">
예상: {{ is_array($log['expect']['status']) ? implode(', ', $log['expect']['status']) : $log['expect']['status'] }}
</span>
@endif
</div>
@if(!empty($log['response']['body']))
<details class="mt-2">
<summary class="cursor-pointer text-xs text-gray-500 hover:text-gray-700">응답 데이터</summary>
<pre class="mt-1 p-2 bg-gray-100 rounded text-xs overflow-x-auto max-h-60 overflow-y-auto">{{ json_encode($log['response']['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
</details>
@endif
</div>
</div>
@endif
{{-- 성공/실패 이유 --}}
@if(isset($log['reason']))
<div class="text-sm {{ ($log['success'] ?? false) ? 'text-green-700 bg-green-100' : 'text-red-700 bg-red-100' }} px-3 py-2 rounded">
<span class="font-medium">{{ ($log['success'] ?? false) ? '성공' : '실패' }} 이유:</span>
{{ $log['reason'] }}
</div>
@endif
{{-- 스텝 설명 --}}
@if(isset($log['description']) && $log['description'])
<div class="text-xs text-gray-500 italic">
{{ $log['description'] }}
</div>
@endif
{{-- 에러 상세 --}}
@if(isset($log['error']))
<div class="text-sm bg-red-100 rounded p-3">
<div class="font-medium text-red-800 mb-1">에러</div>
<div class="text-red-700">{{ $log['error'] }}</div>
</div>
@endif
</div>
</div>
<span class="text-sm text-gray-500">{{ $log['duration'] ?? '' }}ms</span>
</div>
@endforeach
</div>
@else
<div class="text-center py-8 text-gray-500">
<p>실행 로그가 없습니다.</p>
</div>
@endif
</div>
@if(isset($log['request']))
<div class="text-sm mb-2">
<span class="font-medium text-gray-600">Request:</span>
<code class="ml-2 text-gray-800">{{ $log['request']['method'] ?? '' }} {{ $log['request']['endpoint'] ?? '' }}</code>
<!-- API 로그 -->
<div x-show="activeTab === 'api'" class="p-6">
@if($run->api_logs && count($run->api_logs) > 0)
<div class="space-y-3">
@foreach($run->api_logs as $index => $apiLog)
@php
$isRequest = ($apiLog['type'] ?? '') === 'request';
$isError = !$isRequest && (($apiLog['status'] ?? 0) >= 400 || ($apiLog['success'] ?? true) === false);
@endphp
<div class="border rounded-lg p-3 {{ $isRequest ? 'border-blue-200 bg-blue-50' : ($isError ? 'border-red-200 bg-red-50' : 'border-green-200 bg-green-50') }}">
<div class="flex justify-between items-start">
<div class="flex items-center gap-2">
@if($isRequest)
<span class="px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-700 rounded">REQ</span>
@else
<span class="px-2 py-0.5 text-xs font-medium {{ $isError ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700' }} rounded">RES</span>
@endif
@if($isRequest)
<code class="text-sm font-medium text-gray-800">
{{ $apiLog['method'] ?? '' }} {{ $apiLog['uri'] ?? '' }}
</code>
@else
<code class="text-sm text-gray-800">
{{ $apiLog['uri'] ?? '' }}
</code>
<span class="px-2 py-0.5 text-xs font-medium {{ $isError ? 'bg-red-600 text-white' : 'bg-green-600 text-white' }} rounded">
{{ $apiLog['status'] ?? '-' }}
</span>
@endif
</div>
<span class="text-xs text-gray-500">{{ $apiLog['timestamp'] ?? '' }}</span>
</div>
@endif
@if(isset($log['response']))
<div class="text-sm">
<span class="font-medium text-gray-600">Response:</span>
<span class="ml-2 {{ ($log['response']['status'] ?? 0) < 400 ? 'text-green-600' : 'text-red-600' }}">
{{ $log['response']['status'] ?? '-' }}
</span>
@if(isset($log['expect']['status']))
<span class="ml-2 text-gray-500">
(예상: {{ is_array($log['expect']['status']) ? implode(', ', $log['expect']['status']) : $log['expect']['status'] }})
</span>
@if($isRequest && !empty($apiLog['input']))
<div class="mt-2">
<details class="text-sm">
<summary class="cursor-pointer text-gray-600 hover:text-gray-800">요청 데이터</summary>
<pre class="mt-1 p-2 bg-gray-100 rounded text-xs overflow-x-auto">{{ json_encode($apiLog['input'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
</details>
</div>
@endif
@if(!$isRequest)
@if(isset($apiLog['message']) && $apiLog['message'])
<div class="mt-2 text-sm {{ $isError ? 'text-red-700' : 'text-green-700' }}">
{{ $apiLog['message'] }}
</div>
@endif
</div>
@endif
{{-- 성공/실패 이유 설명 --}}
@if(isset($log['reason']))
<div class="mt-2 text-sm {{ ($log['success'] ?? false) ? 'text-green-700 bg-green-100' : 'text-red-700 bg-red-100' }} px-2 py-1 rounded">
{{ $log['reason'] }}
</div>
@endif
{{-- 스텝 설명 --}}
@if(isset($log['description']) && $log['description'])
<div class="mt-1 text-xs text-gray-500 italic">
💡 {{ $log['description'] }}
</div>
@endif
@if(isset($log['error']))
<div class="mt-2 text-sm text-red-600">
{{ $log['error'] }}
</div>
@endif
</div>
@endforeach
</div>
@else
<div class="text-center py-8 text-gray-500">
<p>실행 로그가 없습니다.</p>
</div>
@endif
@if(isset($apiLog['error']))
<div class="mt-2">
<details class="text-sm" open>
<summary class="cursor-pointer text-red-700 font-medium">오류 상세</summary>
<pre class="mt-1 p-2 bg-red-100 rounded text-xs overflow-x-auto text-red-800">{{ is_array($apiLog['error']) ? json_encode($apiLog['error'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) : $apiLog['error'] }}</pre>
</details>
</div>
@endif
@endif
</div>
@endforeach
</div>
@else
<div class="text-center py-8 text-gray-500">
<svg class="w-12 h-12 mx-auto mb-3 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p>API 로그가 없습니다.</p>
<p class="text-xs mt-1">API 서버의 로그 파일에서 캡처됩니다.</p>
</div>
@endif
</div>
</div>
</div>
</div>