@q-agent/agent 0.1.1 → 0.1.7
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 +28 -1
- package/dist/src/api.js +97 -1
- package/dist/src/cli.js +18 -3
- package/dist/src/config.js +25 -0
- package/dist/src/ensureBrowser.js +4 -1
- package/dist/src/paths.js +15 -0
- package/dist/src/playwrightConfig.js +87 -42
- package/dist/src/report.js +4 -0
- package/dist/src/runner.js +354 -63
- package/dist/src/ui.js +400 -161
- package/dist/src/version.js +61 -0
- package/dist/test/api.test.js +50 -0
- package/dist/test/playwrightConfig.test.js +84 -0
- package/dist/test/report.test.js +4 -0
- package/dist/test/version.test.js +21 -0
- package/package.json +36 -2
package/dist/src/runner.js
CHANGED
|
@@ -48,15 +48,17 @@ 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");
|
|
52
53
|
const paths_1 = require("./paths");
|
|
53
54
|
const playwrightConfig_1 = require("./playwrightConfig");
|
|
54
55
|
const report_1 = require("./report");
|
|
55
56
|
const session_1 = require("./session");
|
|
57
|
+
const version_1 = require("./version");
|
|
56
58
|
// Mirrors api/app/config.py's Settings.exec_timeout_s / auth_capture_timeout_s.
|
|
57
59
|
const EXEC_TIMEOUT_MS = 600_000;
|
|
58
60
|
const AUTH_CAPTURE_TIMEOUT_MS = 300_000;
|
|
59
|
-
const IDLE_POLL_MS =
|
|
61
|
+
const IDLE_POLL_MS = 1_000;
|
|
60
62
|
let activeChild = null;
|
|
61
63
|
/** Kill whatever child process (capture browser or Playwright run) is active. Used by the CLI's SIGINT handler. */
|
|
62
64
|
function killActiveChild() {
|
|
@@ -71,7 +73,7 @@ function killActiveChild() {
|
|
|
71
73
|
}
|
|
72
74
|
function nodePathEnv(nm) {
|
|
73
75
|
const existing = process.env.NODE_PATH;
|
|
74
|
-
return { ...process.env, NODE_PATH: existing ? `${nm}${path.delimiter}${existing}` : nm };
|
|
76
|
+
return { ...process.env, ...(0, paths_1.childNodeEnv)(), NODE_PATH: existing ? `${nm}${path.delimiter}${existing}` : nm };
|
|
75
77
|
}
|
|
76
78
|
function runProcess(cmd, args, cwd, env, timeoutMs) {
|
|
77
79
|
return new Promise((resolve) => {
|
|
@@ -166,64 +168,88 @@ async function failAllResults(cfg, job, message) {
|
|
|
166
168
|
const total = job.specs.length;
|
|
167
169
|
await api.postComplete(cfg, job.executionId, { passed: 0, failed: total, log: message }).catch((err) => console.error("postComplete failed:", err));
|
|
168
170
|
}
|
|
171
|
+
/** Reuse a valid local session, or capture one headed — shared by the run + heal paths. */
|
|
172
|
+
async function resolveJobAuth(cfg, job) {
|
|
173
|
+
if (!job.manualAuth)
|
|
174
|
+
return { storageState: "", sessionStoragePath: "" };
|
|
175
|
+
let origin = job.authOrigins[0] || "";
|
|
176
|
+
if (!origin && job.baseUrl) {
|
|
177
|
+
try {
|
|
178
|
+
origin = new URL(job.baseUrl).origin;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
origin = "";
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (origin && (0, session_1.hasValidSession)(origin)) {
|
|
185
|
+
const paths = (0, session_1.sessionPathsForOrigin)(origin);
|
|
186
|
+
return { storageState: paths.storageStatePath, sessionStoragePath: paths.sessionStoragePath };
|
|
187
|
+
}
|
|
188
|
+
if (origin && job.baseUrl) {
|
|
189
|
+
const paths = (0, session_1.sessionPathsForOrigin)(origin);
|
|
190
|
+
await api.postEvent(cfg, job.executionId, "exec.auth.waiting", { url: job.baseUrl });
|
|
191
|
+
(0, bus_1.emit)("auth-waiting", { url: job.baseUrl });
|
|
192
|
+
const captured = await captureAuth(job.baseUrl, paths.storageStatePath, paths.sessionStoragePath);
|
|
193
|
+
if (captured) {
|
|
194
|
+
await api.postEvent(cfg, job.executionId, "exec.auth.captured", {});
|
|
195
|
+
(0, bus_1.emit)("auth-captured", {});
|
|
196
|
+
return { storageState: paths.storageStatePath, sessionStoragePath: paths.sessionStoragePath };
|
|
197
|
+
}
|
|
198
|
+
const message = "Manual login was not completed — enable/redo login capture";
|
|
199
|
+
await api.postEvent(cfg, job.executionId, "exec.auth.error", { message });
|
|
200
|
+
(0, bus_1.emit)("error", { message });
|
|
201
|
+
return { storageState: "", sessionStoragePath: "", error: message };
|
|
202
|
+
}
|
|
203
|
+
const message = "Set a base URL for the project first.";
|
|
204
|
+
await api.postEvent(cfg, job.executionId, "exec.auth.error", { message });
|
|
205
|
+
(0, bus_1.emit)("error", { message });
|
|
206
|
+
return { storageState: "", sessionStoragePath: "", error: message };
|
|
207
|
+
}
|
|
208
|
+
/** Best-effort: read the distilled DOM JSON from a parsed attachment list. */
|
|
209
|
+
function loadDistilledDom(workDir, attachments) {
|
|
210
|
+
const att = attachments.find((a) => a.kind === "dom-distilled");
|
|
211
|
+
if (!att)
|
|
212
|
+
return null;
|
|
213
|
+
const p = path.isAbsolute(att.path) ? att.path : path.join(workDir, att.path);
|
|
214
|
+
try {
|
|
215
|
+
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
169
221
|
/** Process one claimed job end-to-end: write specs/config, resolve auth, run Playwright, push results. */
|
|
170
222
|
async function processJob(cfg, job) {
|
|
223
|
+
if (job.heal) {
|
|
224
|
+
await processHealJob(cfg, job, job.heal);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
171
227
|
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "qagent-"));
|
|
228
|
+
// Set once the (detached) evidence uploader takes ownership of workDir — it
|
|
229
|
+
// then removes the dir when its uploads finish. Until then, this function's
|
|
230
|
+
// finally cleans up (error / early-return paths).
|
|
231
|
+
let handedOff = false;
|
|
172
232
|
try {
|
|
173
233
|
for (const spec of job.specs) {
|
|
174
234
|
fs.writeFileSync(path.join(workDir, spec.filename), spec.code, "utf-8");
|
|
175
235
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
let origin = job.authOrigins[0] || "";
|
|
181
|
-
if (!origin && job.baseUrl) {
|
|
182
|
-
try {
|
|
183
|
-
origin = new URL(job.baseUrl).origin;
|
|
184
|
-
}
|
|
185
|
-
catch {
|
|
186
|
-
origin = "";
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
if (origin && (0, session_1.hasValidSession)(origin)) {
|
|
190
|
-
const paths = (0, session_1.sessionPathsForOrigin)(origin);
|
|
191
|
-
storageState = paths.storageStatePath;
|
|
192
|
-
sessionStoragePath = paths.sessionStoragePath;
|
|
193
|
-
}
|
|
194
|
-
else if (origin && job.baseUrl) {
|
|
195
|
-
const paths = (0, session_1.sessionPathsForOrigin)(origin);
|
|
196
|
-
await api.postEvent(cfg, job.executionId, "exec.auth.waiting", { url: job.baseUrl });
|
|
197
|
-
const captured = await captureAuth(job.baseUrl, paths.storageStatePath, paths.sessionStoragePath);
|
|
198
|
-
if (captured) {
|
|
199
|
-
storageState = paths.storageStatePath;
|
|
200
|
-
sessionStoragePath = paths.sessionStoragePath;
|
|
201
|
-
await api.postEvent(cfg, job.executionId, "exec.auth.captured", {});
|
|
202
|
-
}
|
|
203
|
-
else {
|
|
204
|
-
const message = "Manual login was not completed — enable/redo login capture";
|
|
205
|
-
await api.postEvent(cfg, job.executionId, "exec.auth.error", { message });
|
|
206
|
-
await failAllResults(cfg, job, message);
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
else {
|
|
211
|
-
const message = "Set a base URL for the project first.";
|
|
212
|
-
await api.postEvent(cfg, job.executionId, "exec.auth.error", { message });
|
|
213
|
-
await failAllResults(cfg, job, message);
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
236
|
+
const auth = await resolveJobAuth(cfg, job);
|
|
237
|
+
if (auth.error) {
|
|
238
|
+
await failAllResults(cfg, job, auth.error);
|
|
239
|
+
return;
|
|
216
240
|
}
|
|
241
|
+
const { storageState, sessionStoragePath } = auth;
|
|
217
242
|
(0, playwrightConfig_1.writeConfig)(workDir, job.workers, job.headless, job.baseUrl, storageState);
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
const
|
|
222
|
-
(0, playwrightConfig_1.
|
|
243
|
+
// Always inject the generated fixtures.ts (DOM capture on every run) + rewrite
|
|
244
|
+
// spec imports to it. sessionStorage replay (MSAL/SPA tokens) is additionally
|
|
245
|
+
// enabled only when a manual-auth session actually exists.
|
|
246
|
+
const replaySession = Boolean(job.manualAuth && storageState && sessionStoragePath && fs.statSync(sessionStoragePath).size > 0);
|
|
247
|
+
(0, playwrightConfig_1.applyFixtures)(workDir, job.specs.map((s) => s.filename), sessionStoragePath || path.join(workDir, "sessionStorage.json"), replaySession);
|
|
223
248
|
const total = job.specs.length;
|
|
224
249
|
for (let i = 0; i < job.specs.length; i++) {
|
|
225
250
|
const { ticket, caseCode } = identityFor(job.specs[i]);
|
|
226
251
|
await api.postEvent(cfg, job.executionId, "exec.case.running", { ticket, caseCode, index: i + 1, total });
|
|
252
|
+
(0, bus_1.emit)("case-running", { ticket, caseCode, index: i + 1, total });
|
|
227
253
|
}
|
|
228
254
|
// A single-spec job targets just that one file (the "run this test"
|
|
229
255
|
// action); a multi-case job executes the whole suite — same distinction
|
|
@@ -255,6 +281,11 @@ async function processJob(cfg, job) {
|
|
|
255
281
|
let passed = 0;
|
|
256
282
|
let failed = 0;
|
|
257
283
|
const matched = new Set();
|
|
284
|
+
// Evidence uploads are deferred until AFTER results + complete are posted:
|
|
285
|
+
// the browser has already closed when a test finishes, so the web should mark
|
|
286
|
+
// the case/run done immediately rather than waiting on (potentially multi-MB)
|
|
287
|
+
// video/trace/DOM uploads over the network.
|
|
288
|
+
const pendingEvidence = [];
|
|
258
289
|
for (const spec of job.specs) {
|
|
259
290
|
const entry = parsed.find((e) => path.basename(e.file) === spec.filename);
|
|
260
291
|
if (!entry)
|
|
@@ -271,21 +302,6 @@ async function processJob(cfg, job) {
|
|
|
271
302
|
passed++;
|
|
272
303
|
else if (entry.status === "fail")
|
|
273
304
|
failed++;
|
|
274
|
-
for (const att of entry.attachments) {
|
|
275
|
-
const filePath = path.isAbsolute(att.path) ? att.path : path.join(workDir, att.path);
|
|
276
|
-
if (!fs.existsSync(filePath))
|
|
277
|
-
continue;
|
|
278
|
-
const { ticket, caseCode } = identityFor(spec);
|
|
279
|
-
await api
|
|
280
|
-
.postEvidence(cfg, job.executionId, {
|
|
281
|
-
ticketExternalId: ticket,
|
|
282
|
-
caseCode,
|
|
283
|
-
kind: att.kind,
|
|
284
|
-
filePath,
|
|
285
|
-
filename: path.basename(filePath),
|
|
286
|
-
})
|
|
287
|
-
.catch((err) => console.error("postEvidence failed:", err));
|
|
288
|
-
}
|
|
289
305
|
const { ticket, caseCode } = identityFor(spec);
|
|
290
306
|
await api.postEvent(cfg, job.executionId, "exec.case.result", {
|
|
291
307
|
ticket,
|
|
@@ -293,6 +309,7 @@ async function processJob(cfg, job) {
|
|
|
293
309
|
status: entry.status,
|
|
294
310
|
durationMs,
|
|
295
311
|
});
|
|
312
|
+
(0, bus_1.emit)("case-result", { ticket, caseCode, status: entry.status, durationMs });
|
|
296
313
|
const progress = total ? Math.trunc((100 * matched.size) / total) : 100;
|
|
297
314
|
await api.postEvent(cfg, job.executionId, "exec.progress", {
|
|
298
315
|
progress,
|
|
@@ -300,6 +317,13 @@ async function processJob(cfg, job) {
|
|
|
300
317
|
failed,
|
|
301
318
|
remaining: total - matched.size,
|
|
302
319
|
});
|
|
320
|
+
(0, bus_1.emit)("progress", { progress, passed, failed, remaining: total - matched.size });
|
|
321
|
+
for (const att of entry.attachments) {
|
|
322
|
+
const filePath = path.isAbsolute(att.path) ? att.path : path.join(workDir, att.path);
|
|
323
|
+
if (!fs.existsSync(filePath))
|
|
324
|
+
continue;
|
|
325
|
+
pendingEvidence.push({ ticket, caseCode, kind: att.kind, filePath });
|
|
326
|
+
}
|
|
303
327
|
}
|
|
304
328
|
// Any spec Playwright didn't report on (e.g. run_error) is marked failed.
|
|
305
329
|
for (const spec of job.specs) {
|
|
@@ -325,6 +349,259 @@ async function processJob(cfg, job) {
|
|
|
325
349
|
// matching the server's own log persistence.
|
|
326
350
|
const logText = (procOutput || runError || "").slice(-20000);
|
|
327
351
|
await api.postComplete(cfg, job.executionId, { passed, failed, log: logText });
|
|
352
|
+
(0, bus_1.emit)("job-complete", { executionId: job.executionId, passed, failed });
|
|
353
|
+
// Now that the run is marked done, upload the (deferred) evidence artifacts.
|
|
354
|
+
// These run DETACHED from the claim loop: the run's outcome is already
|
|
355
|
+
// reported, so the agent must be free to claim the next run immediately
|
|
356
|
+
// rather than blocking here until every (potentially multi-MB) artifact
|
|
357
|
+
// finishes uploading — that block is what stalled the agent when a new run
|
|
358
|
+
// was started mid-upload. The uploader owns workDir cleanup from here.
|
|
359
|
+
handedOff = true;
|
|
360
|
+
void uploadEvidenceThenCleanup(cfg, job.executionId, pendingEvidence, workDir);
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
if (!handedOff)
|
|
364
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Upload a completed job's deferred evidence artifacts, then remove its workDir.
|
|
369
|
+
* Runs detached from the claim loop (see {@link processJob}) so uploads never
|
|
370
|
+
* delay claiming the next run. Never throws: each upload's failure is logged and
|
|
371
|
+
* the workDir is always removed, even if some uploads fail or time out.
|
|
372
|
+
*/
|
|
373
|
+
async function uploadEvidenceThenCleanup(cfg, executionId, pendingEvidence, workDir) {
|
|
374
|
+
try {
|
|
375
|
+
for (const ev of pendingEvidence) {
|
|
376
|
+
await api
|
|
377
|
+
.postEvidence(cfg, executionId, {
|
|
378
|
+
ticketExternalId: ev.ticket,
|
|
379
|
+
caseCode: ev.caseCode,
|
|
380
|
+
kind: ev.kind,
|
|
381
|
+
filePath: ev.filePath,
|
|
382
|
+
filename: path.basename(ev.filePath),
|
|
383
|
+
})
|
|
384
|
+
.catch((err) => console.error("postEvidence failed:", err));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Process a standalone manual-login capture: open a headed browser at the
|
|
393
|
+
* project's base URL ON THIS MACHINE, let the operator log in, and save the
|
|
394
|
+
* session locally (keyed by origin) so subsequent runs reuse it. The session
|
|
395
|
+
* never leaves this machine — only the pass/fail outcome is reported back.
|
|
396
|
+
*/
|
|
397
|
+
async function processCapture(cfg, capture) {
|
|
398
|
+
let origin = capture.origin;
|
|
399
|
+
if (!origin && capture.baseUrl) {
|
|
400
|
+
try {
|
|
401
|
+
origin = new URL(capture.baseUrl).origin;
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
origin = "";
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (!origin || !capture.baseUrl) {
|
|
408
|
+
await api.postCaptureComplete(cfg, capture.captureId, false, "Missing base URL / origin").catch(() => { });
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const paths = (0, session_1.sessionPathsForOrigin)(origin);
|
|
412
|
+
// Force a fresh login: remove any prior session so captureAuth's
|
|
413
|
+
// "storageState is non-empty" success check can't pass off a stale file
|
|
414
|
+
// (which would falsely report success without opening a browser).
|
|
415
|
+
try {
|
|
416
|
+
fs.rmSync(paths.storageStatePath, { force: true });
|
|
417
|
+
fs.rmSync(paths.sessionStoragePath, { force: true });
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
// best-effort
|
|
421
|
+
}
|
|
422
|
+
(0, bus_1.emit)("auth-waiting", { url: capture.baseUrl });
|
|
423
|
+
console.log(`Capturing login for ${capture.projectKey} at ${capture.baseUrl}`);
|
|
424
|
+
let ok = false;
|
|
425
|
+
try {
|
|
426
|
+
ok = await captureAuth(capture.baseUrl, paths.storageStatePath, paths.sessionStoragePath);
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
console.error("Capture crashed:", err.message);
|
|
430
|
+
}
|
|
431
|
+
if (ok)
|
|
432
|
+
(0, bus_1.emit)("auth-captured", {});
|
|
433
|
+
else
|
|
434
|
+
(0, bus_1.emit)("error", { message: "Login was not captured — the window closed before a session was saved." });
|
|
435
|
+
await api
|
|
436
|
+
.postCaptureComplete(cfg, capture.captureId, ok, ok ? undefined : "Login was not captured")
|
|
437
|
+
.catch((err) => console.error("postCaptureComplete failed:", err));
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Run the self-heal LOOP for one case locally (#260): run the spec + capture DOM,
|
|
441
|
+
* and while it fails ask the server to classify + fix it (Claude + KB live
|
|
442
|
+
* server-side), apply the returned code, and re-run — up to `maxAttempts`. Streams
|
|
443
|
+
* `heal.progress`, uploads the final attempt's result + evidence, then posts the
|
|
444
|
+
* outcome to `/agent/heal/{caseId}/finalize`.
|
|
445
|
+
*/
|
|
446
|
+
async function processHealJob(cfg, job, heal) {
|
|
447
|
+
const spec = job.specs[0];
|
|
448
|
+
const { ticket, caseCode } = identityFor(spec);
|
|
449
|
+
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "qagent-heal-"));
|
|
450
|
+
const progress = (phase, attempt, message, error = "") => api
|
|
451
|
+
.postEvent(cfg, job.executionId, "heal.progress", {
|
|
452
|
+
caseId: heal.caseId, ticket, caseCode, attempt, maxAttempts: heal.maxAttempts,
|
|
453
|
+
phase, message, error: (error || "").slice(0, 600),
|
|
454
|
+
})
|
|
455
|
+
.catch(() => { });
|
|
456
|
+
let currentCode = spec.code;
|
|
457
|
+
let finalStatus = "fail";
|
|
458
|
+
let finalError = "";
|
|
459
|
+
let elapsedMs = 0;
|
|
460
|
+
let lastDom = null;
|
|
461
|
+
let lastAttachments = [];
|
|
462
|
+
let lastFixBefore = null;
|
|
463
|
+
let lastFixAfter = null;
|
|
464
|
+
let blockReason = "";
|
|
465
|
+
let gateReport = "";
|
|
466
|
+
const attempts = [];
|
|
467
|
+
try {
|
|
468
|
+
const auth = await resolveJobAuth(cfg, job);
|
|
469
|
+
if (auth.error) {
|
|
470
|
+
finalError = auth.error;
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
const { storageState, sessionStoragePath } = auth;
|
|
474
|
+
for (let attempt = 1; attempt <= heal.maxAttempts; attempt++) {
|
|
475
|
+
fs.writeFileSync(path.join(workDir, spec.filename), currentCode, "utf-8");
|
|
476
|
+
(0, playwrightConfig_1.writeConfig)(workDir, 1, job.headless, job.baseUrl, storageState);
|
|
477
|
+
const replaySession = Boolean(job.manualAuth && storageState && sessionStoragePath && fs.statSync(sessionStoragePath).size > 0);
|
|
478
|
+
(0, playwrightConfig_1.applyFixtures)(workDir, [spec.filename], sessionStoragePath || path.join(workDir, "sessionStorage.json"), replaySession);
|
|
479
|
+
await progress("running", attempt, `Running spec (attempt ${attempt}/${heal.maxAttempts})`);
|
|
480
|
+
const started = Date.now();
|
|
481
|
+
const { stdout, stderr, timedOut } = await runPlaywright(workDir, 1, spec.filename);
|
|
482
|
+
elapsedMs = Date.now() - started;
|
|
483
|
+
const output = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
484
|
+
let entry;
|
|
485
|
+
const reportPath = path.join(workDir, "report.json");
|
|
486
|
+
if (!timedOut && fs.existsSync(reportPath)) {
|
|
487
|
+
try {
|
|
488
|
+
const parsed = (0, report_1.parsePlaywrightReport)(JSON.parse(fs.readFileSync(reportPath, "utf-8")));
|
|
489
|
+
entry = parsed.find((e) => path.basename(e.file) === spec.filename);
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
// fall through to the no-report error below
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
const rec = { attempt };
|
|
496
|
+
if (entry) {
|
|
497
|
+
lastAttachments = entry.attachments;
|
|
498
|
+
lastDom = loadDistilledDom(workDir, entry.attachments);
|
|
499
|
+
}
|
|
500
|
+
if (entry && entry.status === "pass") {
|
|
501
|
+
finalStatus = "pass";
|
|
502
|
+
finalError = "";
|
|
503
|
+
rec.status = "pass";
|
|
504
|
+
attempts.push(rec);
|
|
505
|
+
await progress("passed", attempt, "Spec passed");
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
finalStatus = "fail";
|
|
509
|
+
finalError = entry
|
|
510
|
+
? entry.error_message || output || "Test failed"
|
|
511
|
+
: timedOut
|
|
512
|
+
? "Playwright run timed out"
|
|
513
|
+
: output || "No result reported by Playwright";
|
|
514
|
+
rec.status = "fail";
|
|
515
|
+
rec.error = finalError;
|
|
516
|
+
if (attempt >= heal.maxAttempts) {
|
|
517
|
+
attempts.push(rec);
|
|
518
|
+
await progress("failed", attempt, "Still failing after max attempts", finalError);
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
await progress("fixing", attempt, "Asking the server to fix the spec", finalError);
|
|
522
|
+
let fix;
|
|
523
|
+
try {
|
|
524
|
+
fix = await api.postHealFix(cfg, heal.caseId, {
|
|
525
|
+
currentCode, error: finalError, output, domDistilled: lastDom, attempt,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
catch (err) {
|
|
529
|
+
finalError = `Heal fix request failed: ${err.message}`;
|
|
530
|
+
rec.error = finalError;
|
|
531
|
+
attempts.push(rec);
|
|
532
|
+
await progress("failed", attempt, finalError, finalError);
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
rec.action = fix.action;
|
|
536
|
+
if (fix.action === "product_defect") {
|
|
537
|
+
finalStatus = "product_defect";
|
|
538
|
+
rec.failureClass = fix.failureClass;
|
|
539
|
+
rec.reason = fix.reason;
|
|
540
|
+
attempts.push(rec);
|
|
541
|
+
await progress("product_defect", attempt, "Product defect suspected — routing to report", finalError);
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
if (fix.action === "blocked") {
|
|
545
|
+
finalStatus = "blocked";
|
|
546
|
+
blockReason = fix.reason || "";
|
|
547
|
+
gateReport = fix.gate || "";
|
|
548
|
+
rec.gate = "blocked";
|
|
549
|
+
rec.error = fix.reason;
|
|
550
|
+
attempts.push(rec);
|
|
551
|
+
await progress("failed", attempt, "Fix blocked by placeholder gate", fix.reason || "");
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
if (fix.action === "rejected") {
|
|
555
|
+
finalStatus = "fail";
|
|
556
|
+
rec.gate = "rejected";
|
|
557
|
+
rec.error = fix.reason;
|
|
558
|
+
attempts.push(rec);
|
|
559
|
+
await progress("failed", attempt, "Fix rejected", fix.reason || "");
|
|
560
|
+
break;
|
|
561
|
+
}
|
|
562
|
+
// action === "fixed": apply it and re-run.
|
|
563
|
+
rec.fixed = true;
|
|
564
|
+
rec.diff = fix.diff;
|
|
565
|
+
attempts.push(rec);
|
|
566
|
+
lastFixBefore = currentCode;
|
|
567
|
+
lastFixAfter = fix.code || currentCode;
|
|
568
|
+
currentCode = fix.code || currentCode;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
// Report the final attempt's result + evidence against the heal execution.
|
|
572
|
+
const passResult = finalStatus === "pass";
|
|
573
|
+
await api
|
|
574
|
+
.postResult(cfg, job.executionId, {
|
|
575
|
+
file: spec.filename, status: passResult ? "pass" : "fail",
|
|
576
|
+
duration_ms: elapsedMs, error_message: passResult ? "" : finalError,
|
|
577
|
+
})
|
|
578
|
+
.catch((err) => console.error("postResult failed:", err));
|
|
579
|
+
for (const att of lastAttachments) {
|
|
580
|
+
const filePath = path.isAbsolute(att.path) ? att.path : path.join(workDir, att.path);
|
|
581
|
+
if (!fs.existsSync(filePath))
|
|
582
|
+
continue;
|
|
583
|
+
await api
|
|
584
|
+
.postEvidence(cfg, job.executionId, {
|
|
585
|
+
ticketExternalId: ticket, caseCode, kind: att.kind, filePath, filename: path.basename(filePath),
|
|
586
|
+
})
|
|
587
|
+
.catch((err) => console.error("postEvidence failed:", err));
|
|
588
|
+
}
|
|
589
|
+
await api
|
|
590
|
+
.postEvent(cfg, job.executionId, "exec.case.result", {
|
|
591
|
+
ticket, caseCode, status: passResult ? "pass" : "fail", durationMs: elapsedMs,
|
|
592
|
+
})
|
|
593
|
+
.catch(() => { });
|
|
594
|
+
await api
|
|
595
|
+
.postHealFinalize(cfg, heal.caseId, {
|
|
596
|
+
finalStatus, finalError, finalCode: currentCode,
|
|
597
|
+
blockReason, gateReport, domDistilled: lastDom,
|
|
598
|
+
lastFixBefore, lastFixAfter, attempts,
|
|
599
|
+
})
|
|
600
|
+
.catch((err) => console.error("postHealFinalize failed:", err));
|
|
601
|
+
await api
|
|
602
|
+
.postComplete(cfg, job.executionId, { passed: passResult ? 1 : 0, failed: passResult ? 0 : 1, log: finalError })
|
|
603
|
+
.catch((err) => console.error("postComplete failed:", err));
|
|
604
|
+
(0, bus_1.emit)("job-complete", { executionId: job.executionId, passed: passResult ? 1 : 0, failed: passResult ? 0 : 1 });
|
|
328
605
|
}
|
|
329
606
|
finally {
|
|
330
607
|
fs.rmSync(workDir, { recursive: true, force: true });
|
|
@@ -339,7 +616,7 @@ async function runAgentLoop(cfg, signal) {
|
|
|
339
616
|
console.error("Chromium is required to run tests — aborting.");
|
|
340
617
|
return;
|
|
341
618
|
}
|
|
342
|
-
console.log(`Local Agent started — polling ${cfg.serverUrl} as device #${cfg.deviceId} (${cfg.deviceName})`);
|
|
619
|
+
console.log(`Local Agent v${(0, version_1.agentVersion)()} started — polling ${cfg.serverUrl} as device #${cfg.deviceId} (${cfg.deviceName})`);
|
|
343
620
|
while (!signal.aborted) {
|
|
344
621
|
let job = null;
|
|
345
622
|
try {
|
|
@@ -349,16 +626,30 @@ async function runAgentLoop(cfg, signal) {
|
|
|
349
626
|
console.error("Claim failed:", err.message);
|
|
350
627
|
}
|
|
351
628
|
if (!job) {
|
|
629
|
+
// No execution queued — check for a standalone login-capture request.
|
|
630
|
+
let capture = null;
|
|
631
|
+
try {
|
|
632
|
+
capture = await api.claimNextCapture(cfg);
|
|
633
|
+
}
|
|
634
|
+
catch (err) {
|
|
635
|
+
console.error("Capture claim failed:", err.message);
|
|
636
|
+
}
|
|
637
|
+
if (capture) {
|
|
638
|
+
await processCapture(cfg, capture);
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
352
641
|
await new Promise((r) => setTimeout(r, IDLE_POLL_MS));
|
|
353
642
|
continue;
|
|
354
643
|
}
|
|
355
644
|
console.log(`Claimed execution #${job.executionId} (run ${job.runCode}, ${job.specs.length} spec(s))`);
|
|
645
|
+
(0, bus_1.emit)("job-claimed", { executionId: job.executionId, runCode: job.runCode, total: job.specs.length });
|
|
356
646
|
try {
|
|
357
647
|
await processJob(cfg, job);
|
|
358
648
|
console.log(`Execution #${job.executionId} complete`);
|
|
359
649
|
}
|
|
360
650
|
catch (err) {
|
|
361
651
|
console.error(`Execution #${job.executionId} crashed:`, err);
|
|
652
|
+
(0, bus_1.emit)("error", { message: `Execution #${job.executionId} crashed: ${err.message}` });
|
|
362
653
|
await api
|
|
363
654
|
.postComplete(cfg, job.executionId, {
|
|
364
655
|
passed: 0,
|