feat: [flow-tester] 의존성 검사, Docker 지원, 인증 자동 주입 기능 추가

FlowExecutor 개선:
- 의존성 스텝 실패 시 후속 스텝 자동 스킵 로직 추가
- Docker 환경 자동 감지 및 내부 URL 변환 (api.sam.kr → nginx)
- SSL 검증 비활성화 및 Host 헤더 설정 지원
- .env에서 API Key/Bearer Token 자동 주입

VariableBinder 개선:
- 임의 stepId 패턴 지원 (page_create_1.tempPageId 등)
- {{$env.VAR_NAME}} 환경변수 플레이스홀더 추가
- {{$auth.token}}, {{$auth.apiKey}} 인증 플레이스홀더 추가

UI 개선:
- SKIPPED 상태 스타일링 (노란색 배경/테두리)
- 행 클릭 시 스텝 상세 확장 기능
- 실행 결과 실시간 표시 개선
This commit is contained in:
2025-11-27 22:20:36 +09:00
parent 28ca71a17e
commit fe902472c1
5 changed files with 474 additions and 29 deletions

View File

@@ -43,6 +43,11 @@ class FlowExecutor
private int $totalSteps = 0;
/**
* 스텝별 성공/실패 추적
*/
private array $stepSuccessMap = [];
public function __construct(
?VariableBinder $binder = null,
?DependencyResolver $resolver = null,
@@ -101,9 +106,26 @@ public function execute(array $flowDefinition, array $inputVariables = []): arra
foreach ($orderedStepIds as $stepId) {
$step = $stepMap[$stepId];
// 의존성 스텝 성공 여부 확인
$dependencyCheck = $this->checkDependencies($step);
if (! $dependencyCheck['canRun']) {
// 의존성 실패로 스킵
$skipResult = $this->buildStepResult($stepId, $step['name'] ?? $stepId, microtime(true), false, [
'skipped' => true,
'skipReason' => $dependencyCheck['reason'],
'failedDependencies' => $dependencyCheck['failedDeps'],
]);
$this->executionLog[] = $skipResult;
$this->stepSuccessMap[$stepId] = false;
continue;
}
$stepResult = $this->executeStep($step);
$this->executionLog[] = $stepResult;
$this->stepSuccessMap[$stepId] = $stepResult['success'];
if ($stepResult['success']) {
$this->completedSteps++;
@@ -133,6 +155,42 @@ public function execute(array $flowDefinition, array $inputVariables = []): arra
}
}
/**
* 의존성 스텝 성공 여부 확인
*
* @param array $step 실행할 스텝
* @return array ['canRun' => bool, 'reason' => string|null, 'failedDeps' => array]
*/
private function checkDependencies(array $step): array
{
$dependencies = $step['dependsOn'] ?? [];
if (empty($dependencies)) {
return ['canRun' => true, 'reason' => null, 'failedDeps' => []];
}
$failedDeps = [];
foreach ($dependencies as $depId) {
// 의존성 스텝이 실행되지 않았거나 실패한 경우
if (! isset($this->stepSuccessMap[$depId])) {
$failedDeps[] = $depId.' (not executed)';
} elseif (! $this->stepSuccessMap[$depId]) {
$failedDeps[] = $depId.' (failed)';
}
}
if (! empty($failedDeps)) {
return [
'canRun' => false,
'reason' => 'Dependency failed: '.implode(', ', $failedDeps),
'failedDeps' => $failedDeps,
];
}
return ['canRun' => true, 'reason' => null, 'failedDeps' => []];
}
/**
* 단일 스텝 실행
*/
@@ -246,9 +304,31 @@ private function validateFlowDefinition(array $definition): void
*/
private function applyConfig(array $config): void
{
// Base URL
// Base URL - Docker 환경 자동 변환
if (isset($config['baseUrl'])) {
$this->httpClient->setBaseUrl($config['baseUrl']);
$baseUrl = $config['baseUrl'];
// Docker 환경에서 외부 URL을 내부 URL로 변환
if ($this->isDockerEnvironment()) {
$parsedUrl = parse_url($baseUrl);
$host = $parsedUrl['host'] ?? '';
// *.sam.kr 도메인을 nginx 컨테이너로 라우팅
if (str_ends_with($host, '.sam.kr') || $host === 'sam.kr') {
$this->httpClient->setHostHeader($host);
$this->httpClient->withoutVerifying();
// URL을 https://nginx/... 로 변환
$internalUrl = preg_replace(
'#https?://[^/]+#',
'https://nginx',
$baseUrl
);
$baseUrl = $internalUrl;
}
}
$this->httpClient->setBaseUrl($baseUrl);
}
// Timeout
@@ -262,13 +342,53 @@ private function applyConfig(array $config): void
$this->httpClient->setDefaultHeaders($config['headers']);
}
// 인증
if (isset($config['apiKey'])) {
$this->httpClient->setApiKey($config['apiKey']);
// 인증 - JSON에 없으면 .env에서 자동 로드
$apiKey = $config['apiKey'] ?? null;
if (empty($apiKey)) {
$apiKey = env('FLOW_TESTER_API_KEY');
} else {
// 플레이스홀더 치환 ({{$auth.apiKey}} 등)
$apiKey = $this->binder->bind($apiKey);
}
if (isset($config['bearerToken'])) {
$this->httpClient->setBearerToken($config['bearerToken']);
if (! empty($apiKey)) {
$this->httpClient->setApiKey($apiKey);
}
$bearerToken = $config['bearerToken'] ?? null;
if (empty($bearerToken)) {
// .env 또는 로그인 사용자의 토큰 사용
$bearerToken = $this->getDefaultBearerToken();
} else {
// 플레이스홀더 치환 ({{$auth.token}} 등)
$bearerToken = $this->binder->bind($bearerToken);
}
if (! empty($bearerToken)) {
$this->httpClient->setBearerToken($bearerToken);
}
}
/**
* 기본 Bearer 토큰 조회
* 우선순위: 사용자 api_token → .env FLOW_TESTER_API_TOKEN
*/
private function getDefaultBearerToken(): ?string
{
$user = auth()->user();
if ($user && ! empty($user->api_token)) {
return $user->api_token;
}
return env('FLOW_TESTER_API_TOKEN');
}
/**
* Docker 환경인지 확인
*/
private function isDockerEnvironment(): bool
{
// Docker 컨테이너 내부에서 실행 중인지 확인
return file_exists('/.dockerenv') || (getenv('DOCKER_CONTAINER') === 'true');
}
/**
@@ -338,6 +458,7 @@ private function reset(): void
$this->status = 'PENDING';
$this->completedSteps = 0;
$this->totalSteps = 0;
$this->stepSuccessMap = [];
$this->binder->reset();
}

View File

@@ -22,6 +22,30 @@ class HttpClient
private ?string $bearerToken = null;
private bool $verifySsl = true;
private ?string $hostHeader = null;
/**
* SSL 검증 비활성화 (Docker 내부 통신용)
*/
public function withoutVerifying(): self
{
$this->verifySsl = false;
return $this;
}
/**
* Host 헤더 설정 (Docker 내부 통신용)
*/
public function setHostHeader(string $host): self
{
$this->hostHeader = $host;
return $this;
}
/**
* 기본 URL 설정
*/
@@ -91,6 +115,16 @@ public function request(string $method, string $endpoint, array $options = []):
$request = Http::timeout($this->timeout)
->withHeaders($headers);
// SSL 검증 비활성화 (Docker 내부 통신용)
if (! $this->verifySsl) {
$request = $request->withoutVerifying();
}
// Host 헤더 추가 (Docker 내부 통신용)
if ($this->hostHeader) {
$request = $request->withHeaders(['Host' => $this->hostHeader]);
}
// API 키 추가
if ($this->apiKey) {
$request = $request->withHeaders(['X-API-KEY' => $this->apiKey]);

View File

@@ -93,7 +93,7 @@ private function validateValue(mixed $actual, mixed $expected, string $path): ?s
if (! is_string($expected) || ! str_starts_with($expected, '@')) {
if ($actual !== $expected) {
return sprintf(
"Path %s: expected %s, got %s",
'Path %s: expected %s, got %s',
$path,
json_encode($expected, JSON_UNESCAPED_UNICODE),
json_encode($actual, JSON_UNESCAPED_UNICODE)

View File

@@ -92,7 +92,7 @@ private function bindString(string $input): string
}
/**
* 내장 변수 처리 ($timestamp, $uuid, $random:N)
* 내장 변수 처리 ($timestamp, $uuid, $random:N, $env.XXX, $auth.token)
*/
private function resolveBuiltins(string $input): string
{
@@ -120,11 +120,47 @@ function ($m) {
// {{$datetime}} → 현재 날짜시간 (Y-m-d H:i:s)
$input = str_replace('{{$datetime}}', date('Y-m-d H:i:s'), $input);
// {{$env.VAR_NAME}} → 환경변수에서 읽기
$input = preg_replace_callback(
'/\{\{\$env\.([A-Z_][A-Z0-9_]*)\}\}/i',
fn ($m) => env($m[1], ''),
$input
);
// {{$auth.token}} → 현재 로그인 사용자의 API 토큰
if (str_contains($input, '{{$auth.token}}')) {
$token = $this->getAuthToken();
$input = str_replace('{{$auth.token}}', $token, $input);
}
// {{$auth.apiKey}} → .env의 API Key
$input = str_replace('{{$auth.apiKey}}', env('FLOW_TESTER_API_KEY', ''), $input);
return $input;
}
/**
* 참조 경로 해석 (step1.pageId → 실제 값)
* 현재 로그인 사용자의 API 토큰 조회
*/
private function getAuthToken(): string
{
$user = auth()->user();
if (! $user) {
return env('FLOW_TESTER_API_TOKEN', '');
}
// 사용자에게 저장된 API 토큰이 있으면 사용
if (! empty($user->api_token)) {
return $user->api_token;
}
// 없으면 .env의 기본 토큰 사용
return env('FLOW_TESTER_API_TOKEN', '');
}
/**
* 참조 경로 해석 (step1.pageId 또는 page_create_1.pageId → 실제 값)
*/
private function resolveReference(string $path): string
{
@@ -137,23 +173,28 @@ private function resolveReference(string $path): string
return $this->valueToString($value);
}
// stepN.xxx → $this->context['steps']['stepN']['xxx']
if (preg_match('/^(step\w+)\.(.+)$/', $path, $m)) {
$stepId = $m[1];
$subPath = $m[2];
// stepId.xxx 패턴 감지 (stepId는 등록된 step의 ID)
// 첫 번째 점(.) 기준으로 분리
$dotPos = strpos($path, '.');
if ($dotPos !== false) {
$potentialStepId = substr($path, 0, $dotPos);
$subPath = substr($path, $dotPos + 1);
// stepN.response.xxx → 전체 응답에서 추출
if (str_starts_with($subPath, 'response.')) {
$responsePath = substr($subPath, 9); // "response." 제거
$value = data_get($this->context['steps'][$stepId]['response'] ?? [], $responsePath, '');
// 등록된 step인지 확인
if (isset($this->context['steps'][$potentialStepId])) {
// stepId.response.xxx → 전체 응답에서 추출
if (str_starts_with($subPath, 'response.')) {
$responsePath = substr($subPath, 9); // "response." 제거
$value = data_get($this->context['steps'][$potentialStepId]['response'] ?? [], $responsePath, '');
return $this->valueToString($value);
}
// stepId.xxx → extracted 또는 직접 접근
$value = data_get($this->context['steps'][$potentialStepId] ?? [], $subPath, '');
return $this->valueToString($value);
}
// stepN.xxx → extracted 또는 직접 접근
$value = data_get($this->context['steps'][$stepId] ?? [], $subPath, '');
return $this->valueToString($value);
}
// 기타 경로는 context에서 직접 조회