e2e-agent-runner 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/discover.mjs +61 -30
  2. package/package.json +1 -1
  3. package/runner.mjs +14 -11
package/discover.mjs CHANGED
@@ -25,13 +25,16 @@ const PLAYWRIGHT_MCP = process.env.E2E_PLAYWRIGHT_MCP || '@playwright/mcp@0.0.84
25
25
  const ARTIFACT_MAX_BYTES = 6 * 1024 * 1024; // step_result 본문 상한(서버가 다시 정규화·절단)
26
26
  const ROUTE_FILE_MAX_BYTES = 1024 * 1024;
27
27
 
28
- /** 산출 폴더 준비 + 브라우저 MCP 설정 → { outDir, routesDir, extraArgs, browser, promptAddon, cleanup } */
29
- export function prepareDiscover({ siteUrl }) {
28
+ /** 산출 폴더 준비 + 브라우저 MCP 설정 → { outDir, routesDir, resultsDir, extraArgs, browser, promptAddon, cleanup }
29
+ * results=true(QA 페이즈)면 results/ 폴더도 만들고 산출 안내에 포함한다. */
30
+ export function prepareDiscover({ siteUrl, results = false }) {
30
31
  const outDir = mkdtempSync(join(tmpdir(), 'e2e-fm-'));
31
32
  const routesDir = join(outDir, 'routes');
33
+ const resultsDir = join(outDir, 'results');
32
34
  const shotsDir = join(outDir, 'shots');
33
35
  mkdirSync(routesDir, { recursive: true });
34
36
  mkdirSync(shotsDir, { recursive: true });
37
+ if (results) mkdirSync(resultsDir, { recursive: true });
35
38
 
36
39
  const mode = (process.env.E2E_BROWSER_MCP || '').trim();
37
40
  let mcpPath = null;
@@ -67,6 +70,10 @@ export function prepareDiscover({ siteUrl }) {
67
70
  '# 산출물 위치(러너 지정 — 이 경로에만 쓰기)',
68
71
  `- 라우트 파일 폴더: \`${routesDir}\` (예: ${join(routesDir, '001.json')})`,
69
72
  `- 서비스 요약 파일: \`${join(outDir, 'map.json')}\``,
73
+ ...(results ? [
74
+ `- QA 결과 파일 폴더: \`${resultsDir}\` (예: ${join(resultsDir, '001-<id8>.json')})`,
75
+ `- 추가 산출 파일(QA 계획·재검증 페이즈): \`${join(outDir, 'plan.json')}\` · \`${join(outDir, 'retest.json')}\``,
76
+ ] : []),
70
77
  `- 스크린샷 등 브라우저 산출물은 \`${shotsDir}\` 에 저장됩니다(저장소에 저장 금지).`,
71
78
  '',
72
79
  '# 브라우저 도구',
@@ -91,7 +98,7 @@ export function prepareDiscover({ siteUrl }) {
91
98
  }
92
99
  };
93
100
 
94
- return { outDir, routesDir, extraArgs, browser, promptAddon, cleanup };
101
+ return { outDir, routesDir, resultsDir, extraArgs, browser, promptAddon, cleanup };
95
102
  }
96
103
 
97
104
  /** 기록된 라우트 파일 수(진행률 이벤트용). */
@@ -118,6 +125,8 @@ function fallbackFromText(text) {
118
125
  const v = JSON.parse(blocks[i][1]);
119
126
  if (v && Array.isArray(v.routes)) return v;
120
127
  if (v && typeof v.path === 'string') return { routes: [v] };
128
+ if (v && Array.isArray(v.results)) return { results: v.results };
129
+ if (v && Array.isArray(v.items)) return { plan: v };
121
130
  } catch {
122
131
  /* 다음 블록 */
123
132
  }
@@ -125,8 +134,28 @@ function fallbackFromText(text) {
125
134
  return null;
126
135
  }
127
136
 
128
- /** 산출 폴더 수집 → artifact {map, routes[], partial, errors[], browser}. */
129
- export function collectArtifact({ outDir, routesDir, browser, finalText, partial }) {
137
+ /** 폴더의 *.json 을 정렬 순서로 파싱 적재(parse 실패는 errors 기록). */
138
+ function readJsonDir(dir, list, errors) {
139
+ let files = [];
140
+ try {
141
+ files = readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
142
+ } catch {
143
+ return;
144
+ }
145
+ for (const f of files) {
146
+ try {
147
+ const v = readJson(join(dir, f), ROUTE_FILE_MAX_BYTES);
148
+ if (Array.isArray(v)) list.push(...v);
149
+ else list.push(v);
150
+ } catch (e) {
151
+ errors.push(`${f} 파싱 실패: ${e.message}`.slice(0, 300));
152
+ }
153
+ }
154
+ }
155
+
156
+ /** 산출 폴더 수집 → artifact {map, routes[], results[], plan, retest, partial, errors[], browser}.
157
+ * feature_map 은 routes/map 을, QA 페이즈는 results/plan/retest 를 기록한다(서버가 페이즈별 해석). */
158
+ export function collectArtifact({ outDir, routesDir, resultsDir, browser, finalText, partial }) {
130
159
  const errors = [];
131
160
  let map = null;
132
161
  const mapPath = join(outDir, 'map.json');
@@ -136,46 +165,48 @@ export function collectArtifact({ outDir, routesDir, browser, finalText, partial
136
165
  } catch (e) {
137
166
  errors.push(`map.json 파싱 실패: ${e.message}`.slice(0, 300));
138
167
  }
139
- } else {
168
+ } else if (!resultsDir) {
140
169
  errors.push('map.json 없음(요약·커버리지 누락)');
141
170
  }
142
171
 
143
172
  const routes = [];
144
- let files = [];
145
- try {
146
- files = readdirSync(routesDir).filter((f) => f.endsWith('.json')).sort();
147
- } catch {
148
- /* 폴더 없음 */
149
- }
150
- for (const f of files) {
151
- try {
152
- const v = readJson(join(routesDir, f), ROUTE_FILE_MAX_BYTES);
153
- if (Array.isArray(v)) routes.push(...v); // 한 파일에 여러 라우트를 쓴 경우도 수용
154
- else routes.push(v);
155
- } catch (e) {
156
- errors.push(`${f} 파싱 실패: ${e.message}`.slice(0, 300));
173
+ readJsonDir(routesDir, routes, errors);
174
+ const results = [];
175
+ if (resultsDir) readJsonDir(resultsDir, results, errors);
176
+
177
+ let plan = null;
178
+ let retest = null;
179
+ for (const [name, key] of [['plan.json', 'plan'], ['retest.json', 'retest']]) {
180
+ const p = join(outDir, name);
181
+ if (existsSync(p)) {
182
+ try {
183
+ const v = readJson(p, ROUTE_FILE_MAX_BYTES);
184
+ if (key === 'plan') plan = v; else retest = v;
185
+ } catch (e) {
186
+ errors.push(`${name} 파싱 실패: ${e.message}`.slice(0, 300));
187
+ }
157
188
  }
158
189
  }
159
- if (!routes.length) {
190
+
191
+ if (!routes.length && !results.length && !plan && !retest) {
160
192
  const fb = fallbackFromText(finalText);
161
193
  if (fb) {
162
- routes.push(...fb.routes);
163
- if (!map && fb.map) map = fb.map;
164
- errors.push('라우트 파일이 없어 최종 보고의 JSON 블록을 사용');
194
+ if (fb.routes) routes.push(...fb.routes);
195
+ if (fb.results) results.push(...fb.results);
196
+ if (fb.map && !map) map = fb.map;
197
+ errors.push('산출 파일이 없어 최종 보고의 JSON 블록을 사용');
165
198
  }
166
199
  }
167
200
 
168
- const artifact = { map, routes, partial: Boolean(partial), errors, browser };
169
- // 본문 상한 — 초과 시 뒤쪽 라우트부터 제외(Claude 는 핵심 화면부터 기록한다).
201
+ const artifact = { map, routes, results, plan, retest, partial: Boolean(partial), errors, browser };
202
+ // 본문 상한 — 초과 시 뒤쪽 라우트/결과부터 제외(Claude 는 핵심 항목부터 기록한다).
170
203
  let size = Buffer.byteLength(JSON.stringify(artifact));
171
- while (size > ARTIFACT_MAX_BYTES && artifact.routes.length > 1) {
172
- artifact.routes.pop();
204
+ while (size > ARTIFACT_MAX_BYTES && (artifact.routes.length > 1 || artifact.results.length > 1)) {
205
+ if (artifact.routes.length >= artifact.results.length) artifact.routes.pop();
206
+ else artifact.results.pop();
173
207
  artifact.partial = true;
174
208
  size = Buffer.byteLength(JSON.stringify(artifact));
175
209
  }
176
- if (artifact.routes.length < routes.length) {
177
- artifact.errors.push(`산출물 용량 상한으로 라우트 ${routes.length - artifact.routes.length}개 제외`);
178
- }
179
210
  return artifact;
180
211
  }
181
212
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "e2e-agent-runner",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "suseona e2e 작업 에이전트 로컬 러너 — 내 컴퓨터의 Claude Code 로 구현→테스트→리뷰→배포와 웹사이트 기능 지도 추출을 자동 수행",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/runner.mjs CHANGED
@@ -166,7 +166,7 @@ async function handleStep({ step, task, project }) {
166
166
  result = await runImplement({ step, task, project, buf, cancelled });
167
167
  } else if (step.phase === 'deploy') {
168
168
  result = await runDeploy({ step, task, project, buf, cancelled });
169
- } else if (step.phase === 'discover') {
169
+ } else if (step.phase === 'discover' || step.phase.startsWith('qa_')) {
170
170
  result = await runDiscover({ step, task, project, buf, cancelled });
171
171
  } else {
172
172
  // test / remediate / review — 서버 프롬프트(prompt_md)·세션(resume) 그대로 실행.
@@ -222,28 +222,30 @@ async function runImplement({ step, task, project, buf, cancelled }) {
222
222
  return { ...result, commits };
223
223
  }
224
224
 
225
- /** discover — 기능 지도 추출(읽기 전용). 브랜치·프리플라이트 없이 프로젝트 폴더에서 claude 를
226
- * 브라우저 MCP 와 함께 실행하고, 임시 산출 폴더의 기록분을 artifact 로 수집해 보고한다.
227
- * 시간 초과·실패여도 수집분이 있으면 보고(서버가 partial 로 보존). */
225
+ /** discover — 기능 지도 추출·QA 페이즈(qa_run/qa_plan/qa_verify — 읽기 전용). 브랜치·프리플라이트
226
+ * 없이 프로젝트 폴더에서 claude 를 브라우저 MCP 와 함께 실행하고, 임시 산출 폴더의 기록분을
227
+ * artifact 로 수집해 보고한다. 시간 초과·실패여도 수집분이 있으면 보고(서버가 partial 로 보존).
228
+ * qa_plan 은 직전 qa_run 세션을 resume 한다(문제를 직접 관찰한 컨텍스트 위에서 계획). */
228
229
  async function runDiscover({ step, task, project, buf, cancelled }) {
229
230
  if (!state.claudeBin) throw new Error('claude CLI 를 찾을 수 없습니다 — CLAUDE_BIN 확인 후 러너를 재시작하세요.');
230
- const prep = prepareDiscover({ siteUrl: project.site_url });
231
- log(`기능 지도 탐색 시작: ${project.site_url ?? '(사이트 없음)'} — browser=${prep.browser}, out=${prep.outDir}`);
232
- buf.push(step.id, 'status', { event: 'fm_started', browser: prep.browser });
231
+ const isQa = step.phase.startsWith('qa_');
232
+ const prep = prepareDiscover({ siteUrl: project.site_url, results: isQa });
233
+ log(`${isQa ? 'QA' : '기능 지도'} 탐색 시작: ${project.site_url ?? '(사이트 없음)'} — browser=${prep.browser}, out=${prep.outDir}`);
234
+ buf.push(step.id, 'status', { event: 'fm_started', phase: step.phase, browser: prep.browser });
233
235
  const before = await porcelain(project.folder_path);
234
236
  let lastCount = 0;
235
237
  const progress = setInterval(() => {
236
- const n = countRouteFiles(prep.routesDir);
238
+ const n = countRouteFiles(prep.routesDir) + countRouteFiles(prep.resultsDir);
237
239
  if (n !== lastCount) {
238
240
  lastCount = n;
239
- buf.push(step.id, 'status', { event: 'fm_progress', routes: n });
241
+ buf.push(step.id, 'status', { event: 'fm_progress', phase: step.phase, files: n });
240
242
  }
241
243
  }, 10_000);
242
244
  try {
243
245
  const result = await runClaude({
244
246
  cwd: project.folder_path,
245
247
  prompt: `${step.prompt_md}\n${prep.promptAddon}\n`,
246
- resumeSessionId: null,
248
+ resumeSessionId: step.phase === 'qa_plan' ? step.session_id : null,
247
249
  maxTurns: MAX_TURNS_DISCOVER,
248
250
  timeoutS: DISCOVER_TIMEOUT_S,
249
251
  extraArgs: prep.extraArgs,
@@ -262,7 +264,8 @@ async function runDiscover({ step, task, project, buf, cancelled }) {
262
264
  partial: !result.ok || flag !== 'pass',
263
265
  });
264
266
  buf.push(step.id, 'status', {
265
- event: 'fm_collected', routes: artifact.routes.length, errors: artifact.errors.slice(0, 5),
267
+ event: 'fm_collected', phase: step.phase,
268
+ routes: artifact.routes.length, results: artifact.results.length, errors: artifact.errors.slice(0, 5),
266
269
  });
267
270
  const after = await porcelain(project.folder_path);
268
271
  if (before !== null && after !== null && after !== before) {