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.
- package/discover.mjs +61 -30
- package/package.json +1 -1
- 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
|
-
|
|
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
|
-
/**
|
|
129
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
for (const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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 (
|
|
164
|
-
|
|
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
|
-
// 본문 상한 — 초과 시 뒤쪽
|
|
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
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 — 기능 지도
|
|
226
|
-
* 브라우저 MCP 와 함께 실행하고, 임시 산출 폴더의 기록분을
|
|
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
|
|
231
|
-
|
|
232
|
-
|
|
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',
|
|
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',
|
|
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) {
|