@q-agent/agent 0.1.0 → 0.1.6
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/README.md +52 -1
- package/dist/src/api.js +54 -0
- package/dist/src/bus.js +27 -0
- package/dist/src/cli.js +20 -6
- package/dist/src/config.js +25 -0
- package/dist/src/ensureBrowser.js +8 -20
- package/dist/src/paths.js +152 -0
- package/dist/src/playwrightConfig.js +87 -42
- package/dist/src/report.js +4 -0
- package/dist/src/runner.js +342 -100
- package/dist/src/ui.js +629 -0
- package/dist/test/api.test.js +19 -0
- package/dist/test/playwrightConfig.test.js +84 -0
- package/dist/test/report.test.js +4 -0
- package/package.json +40 -2
package/dist/src/runner.js
CHANGED
|
@@ -48,14 +48,16 @@ const fs = __importStar(require("fs"));
|
|
|
48
48
|
const os = __importStar(require("os"));
|
|
49
49
|
const path = __importStar(require("path"));
|
|
50
50
|
const api = __importStar(require("./api"));
|
|
51
|
+
const bus_1 = require("./bus");
|
|
51
52
|
const ensureBrowser_1 = require("./ensureBrowser");
|
|
53
|
+
const paths_1 = require("./paths");
|
|
52
54
|
const playwrightConfig_1 = require("./playwrightConfig");
|
|
53
55
|
const report_1 = require("./report");
|
|
54
56
|
const session_1 = require("./session");
|
|
55
57
|
// Mirrors api/app/config.py's Settings.exec_timeout_s / auth_capture_timeout_s.
|
|
56
58
|
const EXEC_TIMEOUT_MS = 600_000;
|
|
57
59
|
const AUTH_CAPTURE_TIMEOUT_MS = 300_000;
|
|
58
|
-
const IDLE_POLL_MS =
|
|
60
|
+
const IDLE_POLL_MS = 1_000;
|
|
59
61
|
let activeChild = null;
|
|
60
62
|
/** Kill whatever child process (capture browser or Playwright run) is active. Used by the CLI's SIGINT handler. */
|
|
61
63
|
function killActiveChild() {
|
|
@@ -68,35 +70,15 @@ function killActiveChild() {
|
|
|
68
70
|
}
|
|
69
71
|
}
|
|
70
72
|
}
|
|
71
|
-
function firstExisting(candidates) {
|
|
72
|
-
for (const c of candidates) {
|
|
73
|
-
if (fs.existsSync(c))
|
|
74
|
-
return c;
|
|
75
|
-
}
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
/** Resolve the vendored capture_auth.cjs, whether running from `dist/` or via ts-node from `src/`. */
|
|
79
|
-
function vendorCaptureScript() {
|
|
80
|
-
const found = firstExisting([
|
|
81
|
-
path.join(__dirname, "..", "..", "vendor", "capture_auth.cjs"),
|
|
82
|
-
path.join(__dirname, "..", "vendor", "capture_auth.cjs"),
|
|
83
|
-
]);
|
|
84
|
-
if (!found)
|
|
85
|
-
throw new Error("capture_auth.cjs not found — the agent package is missing vendor/");
|
|
86
|
-
return found;
|
|
87
|
-
}
|
|
88
|
-
/** The agent package's own node_modules, where @playwright/test + playwright are installed. */
|
|
89
|
-
function agentNodeModules() {
|
|
90
|
-
return (firstExisting([path.join(__dirname, "..", "..", "node_modules"), path.join(__dirname, "..", "node_modules")]) ??
|
|
91
|
-
path.join(__dirname, "..", "..", "node_modules"));
|
|
92
|
-
}
|
|
93
73
|
function nodePathEnv(nm) {
|
|
94
74
|
const existing = process.env.NODE_PATH;
|
|
95
|
-
return { ...process.env, NODE_PATH: existing ? `${nm}${path.delimiter}${existing}` : nm };
|
|
75
|
+
return { ...process.env, ...(0, paths_1.childNodeEnv)(), NODE_PATH: existing ? `${nm}${path.delimiter}${existing}` : nm };
|
|
96
76
|
}
|
|
97
77
|
function runProcess(cmd, args, cwd, env, timeoutMs) {
|
|
98
78
|
return new Promise((resolve) => {
|
|
99
|
-
|
|
79
|
+
// No shell: cmd is always an absolute node path (nodeBin()) and args are
|
|
80
|
+
// absolute script/CLI paths, so this is safe even when paths contain spaces.
|
|
81
|
+
const child = (0, child_process_1.spawn)(cmd, args, { cwd, env });
|
|
100
82
|
activeChild = child;
|
|
101
83
|
let stdout = "";
|
|
102
84
|
let stderr = "";
|
|
@@ -134,9 +116,9 @@ function runProcess(cmd, args, cwd, env, timeoutMs) {
|
|
|
134
116
|
*/
|
|
135
117
|
async function captureAuth(baseUrl, storageStatePath, sessionStoragePath) {
|
|
136
118
|
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
|
|
137
|
-
const script = vendorCaptureScript();
|
|
138
|
-
const nm = agentNodeModules();
|
|
139
|
-
const result = await runProcess(
|
|
119
|
+
const script = (0, paths_1.vendorCaptureScript)();
|
|
120
|
+
const nm = (0, paths_1.agentNodeModules)();
|
|
121
|
+
const result = await runProcess((0, paths_1.nodeBin)(), [script, baseUrl, storageStatePath, sessionStoragePath], nm, nodePathEnv(nm), AUTH_CAPTURE_TIMEOUT_MS);
|
|
140
122
|
if (result.code !== 0) {
|
|
141
123
|
console.error(`Auth capture exited ${result.code}: ${(result.stderr || result.stdout || "").slice(0, 800)}`);
|
|
142
124
|
}
|
|
@@ -147,21 +129,15 @@ async function captureAuth(baseUrl, storageStatePath, sessionStoragePath) {
|
|
|
147
129
|
return false;
|
|
148
130
|
}
|
|
149
131
|
}
|
|
150
|
-
/** Prefer the agent's own installed `playwright` binary over a fresh `npx` fetch. */
|
|
151
|
-
function playwrightCommand() {
|
|
152
|
-
const nm = agentNodeModules();
|
|
153
|
-
const bin = path.join(nm, ".bin", process.platform === "win32" ? "playwright.cmd" : "playwright");
|
|
154
|
-
if (fs.existsSync(bin))
|
|
155
|
-
return { cmd: bin, baseArgs: ["test"] };
|
|
156
|
-
return { cmd: "npx", baseArgs: ["playwright", "test"] };
|
|
157
|
-
}
|
|
158
132
|
async function runPlaywright(workDir, workers, specFile) {
|
|
159
|
-
|
|
160
|
-
|
|
133
|
+
// Invoke Playwright's CLI through the resolved node runtime (`node cli.js test …`)
|
|
134
|
+
// so the same path works from source, via npx, and in a packaged bundle where
|
|
135
|
+
// `node`/`node_modules` live beside the executable rather than on PATH.
|
|
136
|
+
const args = [(0, paths_1.playwrightCli)(), "test", `--workers=${workers}`];
|
|
161
137
|
if (specFile)
|
|
162
138
|
args.push(specFile);
|
|
163
|
-
const nm = agentNodeModules();
|
|
164
|
-
return runProcess(
|
|
139
|
+
const nm = (0, paths_1.agentNodeModules)();
|
|
140
|
+
return runProcess((0, paths_1.nodeBin)(), args, workDir, nodePathEnv(nm), EXEC_TIMEOUT_MS);
|
|
165
141
|
}
|
|
166
142
|
/**
|
|
167
143
|
* Best identity `{ticket, caseCode}` for wire events: prefers explicit
|
|
@@ -191,64 +167,84 @@ async function failAllResults(cfg, job, message) {
|
|
|
191
167
|
const total = job.specs.length;
|
|
192
168
|
await api.postComplete(cfg, job.executionId, { passed: 0, failed: total, log: message }).catch((err) => console.error("postComplete failed:", err));
|
|
193
169
|
}
|
|
170
|
+
/** Reuse a valid local session, or capture one headed — shared by the run + heal paths. */
|
|
171
|
+
async function resolveJobAuth(cfg, job) {
|
|
172
|
+
if (!job.manualAuth)
|
|
173
|
+
return { storageState: "", sessionStoragePath: "" };
|
|
174
|
+
let origin = job.authOrigins[0] || "";
|
|
175
|
+
if (!origin && job.baseUrl) {
|
|
176
|
+
try {
|
|
177
|
+
origin = new URL(job.baseUrl).origin;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
origin = "";
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (origin && (0, session_1.hasValidSession)(origin)) {
|
|
184
|
+
const paths = (0, session_1.sessionPathsForOrigin)(origin);
|
|
185
|
+
return { storageState: paths.storageStatePath, sessionStoragePath: paths.sessionStoragePath };
|
|
186
|
+
}
|
|
187
|
+
if (origin && job.baseUrl) {
|
|
188
|
+
const paths = (0, session_1.sessionPathsForOrigin)(origin);
|
|
189
|
+
await api.postEvent(cfg, job.executionId, "exec.auth.waiting", { url: job.baseUrl });
|
|
190
|
+
(0, bus_1.emit)("auth-waiting", { url: job.baseUrl });
|
|
191
|
+
const captured = await captureAuth(job.baseUrl, paths.storageStatePath, paths.sessionStoragePath);
|
|
192
|
+
if (captured) {
|
|
193
|
+
await api.postEvent(cfg, job.executionId, "exec.auth.captured", {});
|
|
194
|
+
(0, bus_1.emit)("auth-captured", {});
|
|
195
|
+
return { storageState: paths.storageStatePath, sessionStoragePath: paths.sessionStoragePath };
|
|
196
|
+
}
|
|
197
|
+
const message = "Manual login was not completed — enable/redo login capture";
|
|
198
|
+
await api.postEvent(cfg, job.executionId, "exec.auth.error", { message });
|
|
199
|
+
(0, bus_1.emit)("error", { message });
|
|
200
|
+
return { storageState: "", sessionStoragePath: "", error: message };
|
|
201
|
+
}
|
|
202
|
+
const message = "Set a base URL for the project first.";
|
|
203
|
+
await api.postEvent(cfg, job.executionId, "exec.auth.error", { message });
|
|
204
|
+
(0, bus_1.emit)("error", { message });
|
|
205
|
+
return { storageState: "", sessionStoragePath: "", error: message };
|
|
206
|
+
}
|
|
207
|
+
/** Best-effort: read the distilled DOM JSON from a parsed attachment list. */
|
|
208
|
+
function loadDistilledDom(workDir, attachments) {
|
|
209
|
+
const att = attachments.find((a) => a.kind === "dom-distilled");
|
|
210
|
+
if (!att)
|
|
211
|
+
return null;
|
|
212
|
+
const p = path.isAbsolute(att.path) ? att.path : path.join(workDir, att.path);
|
|
213
|
+
try {
|
|
214
|
+
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
194
220
|
/** Process one claimed job end-to-end: write specs/config, resolve auth, run Playwright, push results. */
|
|
195
221
|
async function processJob(cfg, job) {
|
|
222
|
+
if (job.heal) {
|
|
223
|
+
await processHealJob(cfg, job, job.heal);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
196
226
|
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "qagent-"));
|
|
197
227
|
try {
|
|
198
228
|
for (const spec of job.specs) {
|
|
199
229
|
fs.writeFileSync(path.join(workDir, spec.filename), spec.code, "utf-8");
|
|
200
230
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
let origin = job.authOrigins[0] || "";
|
|
206
|
-
if (!origin && job.baseUrl) {
|
|
207
|
-
try {
|
|
208
|
-
origin = new URL(job.baseUrl).origin;
|
|
209
|
-
}
|
|
210
|
-
catch {
|
|
211
|
-
origin = "";
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
if (origin && (0, session_1.hasValidSession)(origin)) {
|
|
215
|
-
const paths = (0, session_1.sessionPathsForOrigin)(origin);
|
|
216
|
-
storageState = paths.storageStatePath;
|
|
217
|
-
sessionStoragePath = paths.sessionStoragePath;
|
|
218
|
-
}
|
|
219
|
-
else if (origin && job.baseUrl) {
|
|
220
|
-
const paths = (0, session_1.sessionPathsForOrigin)(origin);
|
|
221
|
-
await api.postEvent(cfg, job.executionId, "exec.auth.waiting", { url: job.baseUrl });
|
|
222
|
-
const captured = await captureAuth(job.baseUrl, paths.storageStatePath, paths.sessionStoragePath);
|
|
223
|
-
if (captured) {
|
|
224
|
-
storageState = paths.storageStatePath;
|
|
225
|
-
sessionStoragePath = paths.sessionStoragePath;
|
|
226
|
-
await api.postEvent(cfg, job.executionId, "exec.auth.captured", {});
|
|
227
|
-
}
|
|
228
|
-
else {
|
|
229
|
-
const message = "Manual login was not completed — enable/redo login capture";
|
|
230
|
-
await api.postEvent(cfg, job.executionId, "exec.auth.error", { message });
|
|
231
|
-
await failAllResults(cfg, job, message);
|
|
232
|
-
return;
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
else {
|
|
236
|
-
const message = "Set a base URL for the project first.";
|
|
237
|
-
await api.postEvent(cfg, job.executionId, "exec.auth.error", { message });
|
|
238
|
-
await failAllResults(cfg, job, message);
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
231
|
+
const auth = await resolveJobAuth(cfg, job);
|
|
232
|
+
if (auth.error) {
|
|
233
|
+
await failAllResults(cfg, job, auth.error);
|
|
234
|
+
return;
|
|
241
235
|
}
|
|
236
|
+
const { storageState, sessionStoragePath } = auth;
|
|
242
237
|
(0, playwrightConfig_1.writeConfig)(workDir, job.workers, job.headless, job.baseUrl, storageState);
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
const
|
|
247
|
-
(0, playwrightConfig_1.
|
|
238
|
+
// Always inject the generated fixtures.ts (DOM capture on every run) + rewrite
|
|
239
|
+
// spec imports to it. sessionStorage replay (MSAL/SPA tokens) is additionally
|
|
240
|
+
// enabled only when a manual-auth session actually exists.
|
|
241
|
+
const replaySession = Boolean(job.manualAuth && storageState && sessionStoragePath && fs.statSync(sessionStoragePath).size > 0);
|
|
242
|
+
(0, playwrightConfig_1.applyFixtures)(workDir, job.specs.map((s) => s.filename), sessionStoragePath || path.join(workDir, "sessionStorage.json"), replaySession);
|
|
248
243
|
const total = job.specs.length;
|
|
249
244
|
for (let i = 0; i < job.specs.length; i++) {
|
|
250
245
|
const { ticket, caseCode } = identityFor(job.specs[i]);
|
|
251
246
|
await api.postEvent(cfg, job.executionId, "exec.case.running", { ticket, caseCode, index: i + 1, total });
|
|
247
|
+
(0, bus_1.emit)("case-running", { ticket, caseCode, index: i + 1, total });
|
|
252
248
|
}
|
|
253
249
|
// A single-spec job targets just that one file (the "run this test"
|
|
254
250
|
// action); a multi-case job executes the whole suite — same distinction
|
|
@@ -280,6 +276,11 @@ async function processJob(cfg, job) {
|
|
|
280
276
|
let passed = 0;
|
|
281
277
|
let failed = 0;
|
|
282
278
|
const matched = new Set();
|
|
279
|
+
// Evidence uploads are deferred until AFTER results + complete are posted:
|
|
280
|
+
// the browser has already closed when a test finishes, so the web should mark
|
|
281
|
+
// the case/run done immediately rather than waiting on (potentially multi-MB)
|
|
282
|
+
// video/trace/DOM uploads over the network.
|
|
283
|
+
const pendingEvidence = [];
|
|
283
284
|
for (const spec of job.specs) {
|
|
284
285
|
const entry = parsed.find((e) => path.basename(e.file) === spec.filename);
|
|
285
286
|
if (!entry)
|
|
@@ -296,21 +297,6 @@ async function processJob(cfg, job) {
|
|
|
296
297
|
passed++;
|
|
297
298
|
else if (entry.status === "fail")
|
|
298
299
|
failed++;
|
|
299
|
-
for (const att of entry.attachments) {
|
|
300
|
-
const filePath = path.isAbsolute(att.path) ? att.path : path.join(workDir, att.path);
|
|
301
|
-
if (!fs.existsSync(filePath))
|
|
302
|
-
continue;
|
|
303
|
-
const { ticket, caseCode } = identityFor(spec);
|
|
304
|
-
await api
|
|
305
|
-
.postEvidence(cfg, job.executionId, {
|
|
306
|
-
ticketExternalId: ticket,
|
|
307
|
-
caseCode,
|
|
308
|
-
kind: att.kind,
|
|
309
|
-
filePath,
|
|
310
|
-
filename: path.basename(filePath),
|
|
311
|
-
})
|
|
312
|
-
.catch((err) => console.error("postEvidence failed:", err));
|
|
313
|
-
}
|
|
314
300
|
const { ticket, caseCode } = identityFor(spec);
|
|
315
301
|
await api.postEvent(cfg, job.executionId, "exec.case.result", {
|
|
316
302
|
ticket,
|
|
@@ -318,6 +304,7 @@ async function processJob(cfg, job) {
|
|
|
318
304
|
status: entry.status,
|
|
319
305
|
durationMs,
|
|
320
306
|
});
|
|
307
|
+
(0, bus_1.emit)("case-result", { ticket, caseCode, status: entry.status, durationMs });
|
|
321
308
|
const progress = total ? Math.trunc((100 * matched.size) / total) : 100;
|
|
322
309
|
await api.postEvent(cfg, job.executionId, "exec.progress", {
|
|
323
310
|
progress,
|
|
@@ -325,6 +312,13 @@ async function processJob(cfg, job) {
|
|
|
325
312
|
failed,
|
|
326
313
|
remaining: total - matched.size,
|
|
327
314
|
});
|
|
315
|
+
(0, bus_1.emit)("progress", { progress, passed, failed, remaining: total - matched.size });
|
|
316
|
+
for (const att of entry.attachments) {
|
|
317
|
+
const filePath = path.isAbsolute(att.path) ? att.path : path.join(workDir, att.path);
|
|
318
|
+
if (!fs.existsSync(filePath))
|
|
319
|
+
continue;
|
|
320
|
+
pendingEvidence.push({ ticket, caseCode, kind: att.kind, filePath });
|
|
321
|
+
}
|
|
328
322
|
}
|
|
329
323
|
// Any spec Playwright didn't report on (e.g. run_error) is marked failed.
|
|
330
324
|
for (const spec of job.specs) {
|
|
@@ -350,6 +344,240 @@ async function processJob(cfg, job) {
|
|
|
350
344
|
// matching the server's own log persistence.
|
|
351
345
|
const logText = (procOutput || runError || "").slice(-20000);
|
|
352
346
|
await api.postComplete(cfg, job.executionId, { passed, failed, log: logText });
|
|
347
|
+
(0, bus_1.emit)("job-complete", { executionId: job.executionId, passed, failed });
|
|
348
|
+
// Now that the run is marked done, upload the (deferred) evidence artifacts.
|
|
349
|
+
// The web already reflects the outcome; these trail in the background and the
|
|
350
|
+
// Evidence step picks them up as they land.
|
|
351
|
+
for (const ev of pendingEvidence) {
|
|
352
|
+
await api
|
|
353
|
+
.postEvidence(cfg, job.executionId, {
|
|
354
|
+
ticketExternalId: ev.ticket,
|
|
355
|
+
caseCode: ev.caseCode,
|
|
356
|
+
kind: ev.kind,
|
|
357
|
+
filePath: ev.filePath,
|
|
358
|
+
filename: path.basename(ev.filePath),
|
|
359
|
+
})
|
|
360
|
+
.catch((err) => console.error("postEvidence failed:", err));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
finally {
|
|
364
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Process a standalone manual-login capture: open a headed browser at the
|
|
369
|
+
* project's base URL ON THIS MACHINE, let the operator log in, and save the
|
|
370
|
+
* session locally (keyed by origin) so subsequent runs reuse it. The session
|
|
371
|
+
* never leaves this machine — only the pass/fail outcome is reported back.
|
|
372
|
+
*/
|
|
373
|
+
async function processCapture(cfg, capture) {
|
|
374
|
+
let origin = capture.origin;
|
|
375
|
+
if (!origin && capture.baseUrl) {
|
|
376
|
+
try {
|
|
377
|
+
origin = new URL(capture.baseUrl).origin;
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
origin = "";
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (!origin || !capture.baseUrl) {
|
|
384
|
+
await api.postCaptureComplete(cfg, capture.captureId, false, "Missing base URL / origin").catch(() => { });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const paths = (0, session_1.sessionPathsForOrigin)(origin);
|
|
388
|
+
// Force a fresh login: remove any prior session so captureAuth's
|
|
389
|
+
// "storageState is non-empty" success check can't pass off a stale file
|
|
390
|
+
// (which would falsely report success without opening a browser).
|
|
391
|
+
try {
|
|
392
|
+
fs.rmSync(paths.storageStatePath, { force: true });
|
|
393
|
+
fs.rmSync(paths.sessionStoragePath, { force: true });
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
// best-effort
|
|
397
|
+
}
|
|
398
|
+
(0, bus_1.emit)("auth-waiting", { url: capture.baseUrl });
|
|
399
|
+
console.log(`Capturing login for ${capture.projectKey} at ${capture.baseUrl}`);
|
|
400
|
+
let ok = false;
|
|
401
|
+
try {
|
|
402
|
+
ok = await captureAuth(capture.baseUrl, paths.storageStatePath, paths.sessionStoragePath);
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
console.error("Capture crashed:", err.message);
|
|
406
|
+
}
|
|
407
|
+
if (ok)
|
|
408
|
+
(0, bus_1.emit)("auth-captured", {});
|
|
409
|
+
else
|
|
410
|
+
(0, bus_1.emit)("error", { message: "Login was not captured — the window closed before a session was saved." });
|
|
411
|
+
await api
|
|
412
|
+
.postCaptureComplete(cfg, capture.captureId, ok, ok ? undefined : "Login was not captured")
|
|
413
|
+
.catch((err) => console.error("postCaptureComplete failed:", err));
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Run the self-heal LOOP for one case locally (#260): run the spec + capture DOM,
|
|
417
|
+
* and while it fails ask the server to classify + fix it (Claude + KB live
|
|
418
|
+
* server-side), apply the returned code, and re-run — up to `maxAttempts`. Streams
|
|
419
|
+
* `heal.progress`, uploads the final attempt's result + evidence, then posts the
|
|
420
|
+
* outcome to `/agent/heal/{caseId}/finalize`.
|
|
421
|
+
*/
|
|
422
|
+
async function processHealJob(cfg, job, heal) {
|
|
423
|
+
const spec = job.specs[0];
|
|
424
|
+
const { ticket, caseCode } = identityFor(spec);
|
|
425
|
+
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "qagent-heal-"));
|
|
426
|
+
const progress = (phase, attempt, message, error = "") => api
|
|
427
|
+
.postEvent(cfg, job.executionId, "heal.progress", {
|
|
428
|
+
caseId: heal.caseId, ticket, caseCode, attempt, maxAttempts: heal.maxAttempts,
|
|
429
|
+
phase, message, error: (error || "").slice(0, 600),
|
|
430
|
+
})
|
|
431
|
+
.catch(() => { });
|
|
432
|
+
let currentCode = spec.code;
|
|
433
|
+
let finalStatus = "fail";
|
|
434
|
+
let finalError = "";
|
|
435
|
+
let elapsedMs = 0;
|
|
436
|
+
let lastDom = null;
|
|
437
|
+
let lastAttachments = [];
|
|
438
|
+
let lastFixBefore = null;
|
|
439
|
+
let lastFixAfter = null;
|
|
440
|
+
let blockReason = "";
|
|
441
|
+
let gateReport = "";
|
|
442
|
+
const attempts = [];
|
|
443
|
+
try {
|
|
444
|
+
const auth = await resolveJobAuth(cfg, job);
|
|
445
|
+
if (auth.error) {
|
|
446
|
+
finalError = auth.error;
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
const { storageState, sessionStoragePath } = auth;
|
|
450
|
+
for (let attempt = 1; attempt <= heal.maxAttempts; attempt++) {
|
|
451
|
+
fs.writeFileSync(path.join(workDir, spec.filename), currentCode, "utf-8");
|
|
452
|
+
(0, playwrightConfig_1.writeConfig)(workDir, 1, job.headless, job.baseUrl, storageState);
|
|
453
|
+
const replaySession = Boolean(job.manualAuth && storageState && sessionStoragePath && fs.statSync(sessionStoragePath).size > 0);
|
|
454
|
+
(0, playwrightConfig_1.applyFixtures)(workDir, [spec.filename], sessionStoragePath || path.join(workDir, "sessionStorage.json"), replaySession);
|
|
455
|
+
await progress("running", attempt, `Running spec (attempt ${attempt}/${heal.maxAttempts})`);
|
|
456
|
+
const started = Date.now();
|
|
457
|
+
const { stdout, stderr, timedOut } = await runPlaywright(workDir, 1, spec.filename);
|
|
458
|
+
elapsedMs = Date.now() - started;
|
|
459
|
+
const output = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
460
|
+
let entry;
|
|
461
|
+
const reportPath = path.join(workDir, "report.json");
|
|
462
|
+
if (!timedOut && fs.existsSync(reportPath)) {
|
|
463
|
+
try {
|
|
464
|
+
const parsed = (0, report_1.parsePlaywrightReport)(JSON.parse(fs.readFileSync(reportPath, "utf-8")));
|
|
465
|
+
entry = parsed.find((e) => path.basename(e.file) === spec.filename);
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
// fall through to the no-report error below
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const rec = { attempt };
|
|
472
|
+
if (entry) {
|
|
473
|
+
lastAttachments = entry.attachments;
|
|
474
|
+
lastDom = loadDistilledDom(workDir, entry.attachments);
|
|
475
|
+
}
|
|
476
|
+
if (entry && entry.status === "pass") {
|
|
477
|
+
finalStatus = "pass";
|
|
478
|
+
finalError = "";
|
|
479
|
+
rec.status = "pass";
|
|
480
|
+
attempts.push(rec);
|
|
481
|
+
await progress("passed", attempt, "Spec passed");
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
finalStatus = "fail";
|
|
485
|
+
finalError = entry
|
|
486
|
+
? entry.error_message || output || "Test failed"
|
|
487
|
+
: timedOut
|
|
488
|
+
? "Playwright run timed out"
|
|
489
|
+
: output || "No result reported by Playwright";
|
|
490
|
+
rec.status = "fail";
|
|
491
|
+
rec.error = finalError;
|
|
492
|
+
if (attempt >= heal.maxAttempts) {
|
|
493
|
+
attempts.push(rec);
|
|
494
|
+
await progress("failed", attempt, "Still failing after max attempts", finalError);
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
await progress("fixing", attempt, "Asking the server to fix the spec", finalError);
|
|
498
|
+
let fix;
|
|
499
|
+
try {
|
|
500
|
+
fix = await api.postHealFix(cfg, heal.caseId, {
|
|
501
|
+
currentCode, error: finalError, output, domDistilled: lastDom, attempt,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
catch (err) {
|
|
505
|
+
finalError = `Heal fix request failed: ${err.message}`;
|
|
506
|
+
rec.error = finalError;
|
|
507
|
+
attempts.push(rec);
|
|
508
|
+
await progress("failed", attempt, finalError, finalError);
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
rec.action = fix.action;
|
|
512
|
+
if (fix.action === "product_defect") {
|
|
513
|
+
finalStatus = "product_defect";
|
|
514
|
+
rec.failureClass = fix.failureClass;
|
|
515
|
+
rec.reason = fix.reason;
|
|
516
|
+
attempts.push(rec);
|
|
517
|
+
await progress("product_defect", attempt, "Product defect suspected — routing to report", finalError);
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
if (fix.action === "blocked") {
|
|
521
|
+
finalStatus = "blocked";
|
|
522
|
+
blockReason = fix.reason || "";
|
|
523
|
+
gateReport = fix.gate || "";
|
|
524
|
+
rec.gate = "blocked";
|
|
525
|
+
rec.error = fix.reason;
|
|
526
|
+
attempts.push(rec);
|
|
527
|
+
await progress("failed", attempt, "Fix blocked by placeholder gate", fix.reason || "");
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
530
|
+
if (fix.action === "rejected") {
|
|
531
|
+
finalStatus = "fail";
|
|
532
|
+
rec.gate = "rejected";
|
|
533
|
+
rec.error = fix.reason;
|
|
534
|
+
attempts.push(rec);
|
|
535
|
+
await progress("failed", attempt, "Fix rejected", fix.reason || "");
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
// action === "fixed": apply it and re-run.
|
|
539
|
+
rec.fixed = true;
|
|
540
|
+
rec.diff = fix.diff;
|
|
541
|
+
attempts.push(rec);
|
|
542
|
+
lastFixBefore = currentCode;
|
|
543
|
+
lastFixAfter = fix.code || currentCode;
|
|
544
|
+
currentCode = fix.code || currentCode;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
// Report the final attempt's result + evidence against the heal execution.
|
|
548
|
+
const passResult = finalStatus === "pass";
|
|
549
|
+
await api
|
|
550
|
+
.postResult(cfg, job.executionId, {
|
|
551
|
+
file: spec.filename, status: passResult ? "pass" : "fail",
|
|
552
|
+
duration_ms: elapsedMs, error_message: passResult ? "" : finalError,
|
|
553
|
+
})
|
|
554
|
+
.catch((err) => console.error("postResult failed:", err));
|
|
555
|
+
for (const att of lastAttachments) {
|
|
556
|
+
const filePath = path.isAbsolute(att.path) ? att.path : path.join(workDir, att.path);
|
|
557
|
+
if (!fs.existsSync(filePath))
|
|
558
|
+
continue;
|
|
559
|
+
await api
|
|
560
|
+
.postEvidence(cfg, job.executionId, {
|
|
561
|
+
ticketExternalId: ticket, caseCode, kind: att.kind, filePath, filename: path.basename(filePath),
|
|
562
|
+
})
|
|
563
|
+
.catch((err) => console.error("postEvidence failed:", err));
|
|
564
|
+
}
|
|
565
|
+
await api
|
|
566
|
+
.postEvent(cfg, job.executionId, "exec.case.result", {
|
|
567
|
+
ticket, caseCode, status: passResult ? "pass" : "fail", durationMs: elapsedMs,
|
|
568
|
+
})
|
|
569
|
+
.catch(() => { });
|
|
570
|
+
await api
|
|
571
|
+
.postHealFinalize(cfg, heal.caseId, {
|
|
572
|
+
finalStatus, finalError, finalCode: currentCode,
|
|
573
|
+
blockReason, gateReport, domDistilled: lastDom,
|
|
574
|
+
lastFixBefore, lastFixAfter, attempts,
|
|
575
|
+
})
|
|
576
|
+
.catch((err) => console.error("postHealFinalize failed:", err));
|
|
577
|
+
await api
|
|
578
|
+
.postComplete(cfg, job.executionId, { passed: passResult ? 1 : 0, failed: passResult ? 0 : 1, log: finalError })
|
|
579
|
+
.catch((err) => console.error("postComplete failed:", err));
|
|
580
|
+
(0, bus_1.emit)("job-complete", { executionId: job.executionId, passed: passResult ? 1 : 0, failed: passResult ? 0 : 1 });
|
|
353
581
|
}
|
|
354
582
|
finally {
|
|
355
583
|
fs.rmSync(workDir, { recursive: true, force: true });
|
|
@@ -374,16 +602,30 @@ async function runAgentLoop(cfg, signal) {
|
|
|
374
602
|
console.error("Claim failed:", err.message);
|
|
375
603
|
}
|
|
376
604
|
if (!job) {
|
|
605
|
+
// No execution queued — check for a standalone login-capture request.
|
|
606
|
+
let capture = null;
|
|
607
|
+
try {
|
|
608
|
+
capture = await api.claimNextCapture(cfg);
|
|
609
|
+
}
|
|
610
|
+
catch (err) {
|
|
611
|
+
console.error("Capture claim failed:", err.message);
|
|
612
|
+
}
|
|
613
|
+
if (capture) {
|
|
614
|
+
await processCapture(cfg, capture);
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
377
617
|
await new Promise((r) => setTimeout(r, IDLE_POLL_MS));
|
|
378
618
|
continue;
|
|
379
619
|
}
|
|
380
620
|
console.log(`Claimed execution #${job.executionId} (run ${job.runCode}, ${job.specs.length} spec(s))`);
|
|
621
|
+
(0, bus_1.emit)("job-claimed", { executionId: job.executionId, runCode: job.runCode, total: job.specs.length });
|
|
381
622
|
try {
|
|
382
623
|
await processJob(cfg, job);
|
|
383
624
|
console.log(`Execution #${job.executionId} complete`);
|
|
384
625
|
}
|
|
385
626
|
catch (err) {
|
|
386
627
|
console.error(`Execution #${job.executionId} crashed:`, err);
|
|
628
|
+
(0, bus_1.emit)("error", { message: `Execution #${job.executionId} crashed: ${err.message}` });
|
|
387
629
|
await api
|
|
388
630
|
.postComplete(cfg, job.executionId, {
|
|
389
631
|
passed: 0,
|