@q-agent/agent 0.1.1 → 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.
@@ -48,6 +48,7 @@ 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");
@@ -56,7 +57,7 @@ const session_1 = require("./session");
56
57
  // Mirrors api/app/config.py's Settings.exec_timeout_s / auth_capture_timeout_s.
57
58
  const EXEC_TIMEOUT_MS = 600_000;
58
59
  const AUTH_CAPTURE_TIMEOUT_MS = 300_000;
59
- const IDLE_POLL_MS = 3_000;
60
+ const IDLE_POLL_MS = 1_000;
60
61
  let activeChild = null;
61
62
  /** Kill whatever child process (capture browser or Playwright run) is active. Used by the CLI's SIGINT handler. */
62
63
  function killActiveChild() {
@@ -71,7 +72,7 @@ function killActiveChild() {
71
72
  }
72
73
  function nodePathEnv(nm) {
73
74
  const existing = process.env.NODE_PATH;
74
- 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 };
75
76
  }
76
77
  function runProcess(cmd, args, cwd, env, timeoutMs) {
77
78
  return new Promise((resolve) => {
@@ -166,64 +167,84 @@ async function failAllResults(cfg, job, message) {
166
167
  const total = job.specs.length;
167
168
  await api.postComplete(cfg, job.executionId, { passed: 0, failed: total, log: message }).catch((err) => console.error("postComplete failed:", err));
168
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
+ }
169
220
  /** Process one claimed job end-to-end: write specs/config, resolve auth, run Playwright, push results. */
170
221
  async function processJob(cfg, job) {
222
+ if (job.heal) {
223
+ await processHealJob(cfg, job, job.heal);
224
+ return;
225
+ }
171
226
  const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "qagent-"));
172
227
  try {
173
228
  for (const spec of job.specs) {
174
229
  fs.writeFileSync(path.join(workDir, spec.filename), spec.code, "utf-8");
175
230
  }
176
- // ---- Resolve auth: reuse a valid local session, or capture one headed.
177
- let storageState = "";
178
- let sessionStoragePath = "";
179
- if (job.manualAuth) {
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
- }
231
+ const auth = await resolveJobAuth(cfg, job);
232
+ if (auth.error) {
233
+ await failAllResults(cfg, job, auth.error);
234
+ return;
216
235
  }
236
+ const { storageState, sessionStoragePath } = auth;
217
237
  (0, playwrightConfig_1.writeConfig)(workDir, job.workers, job.headless, job.baseUrl, storageState);
218
- // Replay captured sessionStorage (MSAL/SPA tokens) only when a manual-auth
219
- // session actually exists; otherwise normalize spec imports back to
220
- // '@playwright/test' (mirrors `_apply_auth_fixtures`'s `use_fixtures` gate).
221
- const useFixtures = Boolean(job.manualAuth && storageState && sessionStoragePath && fs.statSync(sessionStoragePath).size > 0);
222
- (0, playwrightConfig_1.applyAuthFixtures)(workDir, job.specs.map((s) => s.filename), sessionStoragePath || path.join(workDir, "sessionStorage.json"), useFixtures);
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);
223
243
  const total = job.specs.length;
224
244
  for (let i = 0; i < job.specs.length; i++) {
225
245
  const { ticket, caseCode } = identityFor(job.specs[i]);
226
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 });
227
248
  }
228
249
  // A single-spec job targets just that one file (the "run this test"
229
250
  // action); a multi-case job executes the whole suite — same distinction
@@ -255,6 +276,11 @@ async function processJob(cfg, job) {
255
276
  let passed = 0;
256
277
  let failed = 0;
257
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 = [];
258
284
  for (const spec of job.specs) {
259
285
  const entry = parsed.find((e) => path.basename(e.file) === spec.filename);
260
286
  if (!entry)
@@ -271,21 +297,6 @@ async function processJob(cfg, job) {
271
297
  passed++;
272
298
  else if (entry.status === "fail")
273
299
  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
300
  const { ticket, caseCode } = identityFor(spec);
290
301
  await api.postEvent(cfg, job.executionId, "exec.case.result", {
291
302
  ticket,
@@ -293,6 +304,7 @@ async function processJob(cfg, job) {
293
304
  status: entry.status,
294
305
  durationMs,
295
306
  });
307
+ (0, bus_1.emit)("case-result", { ticket, caseCode, status: entry.status, durationMs });
296
308
  const progress = total ? Math.trunc((100 * matched.size) / total) : 100;
297
309
  await api.postEvent(cfg, job.executionId, "exec.progress", {
298
310
  progress,
@@ -300,6 +312,13 @@ async function processJob(cfg, job) {
300
312
  failed,
301
313
  remaining: total - matched.size,
302
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
+ }
303
322
  }
304
323
  // Any spec Playwright didn't report on (e.g. run_error) is marked failed.
305
324
  for (const spec of job.specs) {
@@ -325,6 +344,240 @@ async function processJob(cfg, job) {
325
344
  // matching the server's own log persistence.
326
345
  const logText = (procOutput || runError || "").slice(-20000);
327
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 });
328
581
  }
329
582
  finally {
330
583
  fs.rmSync(workDir, { recursive: true, force: true });
@@ -349,16 +602,30 @@ async function runAgentLoop(cfg, signal) {
349
602
  console.error("Claim failed:", err.message);
350
603
  }
351
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
+ }
352
617
  await new Promise((r) => setTimeout(r, IDLE_POLL_MS));
353
618
  continue;
354
619
  }
355
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 });
356
622
  try {
357
623
  await processJob(cfg, job);
358
624
  console.log(`Execution #${job.executionId} complete`);
359
625
  }
360
626
  catch (err) {
361
627
  console.error(`Execution #${job.executionId} crashed:`, err);
628
+ (0, bus_1.emit)("error", { message: `Execution #${job.executionId} crashed: ${err.message}` });
362
629
  await api
363
630
  .postComplete(cfg, job.executionId, {
364
631
  passed: 0,