@q-agent/agent 0.1.23 → 0.1.24

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/dist/src/paths.js CHANGED
@@ -43,6 +43,7 @@ exports.playwrightCli = playwrightCli;
43
43
  exports.claudeCli = claudeCli;
44
44
  exports.vendorCaptureScript = vendorCaptureScript;
45
45
  exports.vendorExploreScript = vendorExploreScript;
46
+ exports.vendorLiveReporter = vendorLiveReporter;
46
47
  exports.vendorAuthoringScript = vendorAuthoringScript;
47
48
  /**
48
49
  * Runtime path resolution for the agent's child processes (Playwright CLI +
@@ -190,6 +191,19 @@ function vendorExploreScript() {
190
191
  throw new Error("explore_session.cjs not found — the agent package is missing vendor/");
191
192
  return found;
192
193
  }
194
+ /** The vendored Playwright live reporter (`live_reporter.cjs`) — emits per-test
195
+ * results so execution status updates immediately instead of at end-of-run. */
196
+ function vendorLiveReporter() {
197
+ const root = packagedRoot();
198
+ const found = firstExisting([
199
+ ...(root ? [path.join(root, "vendor", "live_reporter.cjs")] : []),
200
+ path.join(__dirname, "..", "..", "vendor", "live_reporter.cjs"),
201
+ path.join(__dirname, "..", "vendor", "live_reporter.cjs"),
202
+ ]);
203
+ if (!found)
204
+ throw new Error("live_reporter.cjs not found — the agent package is missing vendor/");
205
+ return found;
206
+ }
193
207
  /** The vendored live-authoring Chrome launcher (`authoring_browser.cjs`, #403).
194
208
  * Uses only Node built-ins (no Playwright), so it runs with a bare `node`. */
195
209
  function vendorAuthoringScript() {
@@ -51,7 +51,7 @@ export default defineConfig({
51
51
  testDir: '.',
52
52
  timeout: __TIMEOUT__,
53
53
  workers: __WORKERS__,
54
- reporter: [['json', { outputFile: 'report.json' }]],
54
+ reporter: __REPORTERS__,
55
55
  use: {
56
56
  headless: __HEADLESS__,
57
57
  screenshot: 'only-on-failure',
@@ -72,7 +72,13 @@ __EXTRA_USE__ },
72
72
  * @param opts Heal-tuning knobs (timeouts + evidence); defaults match a normal run.
73
73
  */
74
74
  function writeConfig(specDir, workers, headless, baseUrl = "", storageState = "", opts = {}) {
75
- const { testTimeoutMs = 30000, actionTimeoutMs, heavyEvidence = true } = opts;
75
+ const { testTimeoutMs = 30000, actionTimeoutMs, heavyEvidence = true, liveReporterPath } = opts;
76
+ // JSON reporter (drives report.json for evidence + reconcile) + optionally the
77
+ // live reporter (streams per-test results). Paths JSON-escaped for Windows.
78
+ const reporters = ["['json', { outputFile: 'report.json' }]"];
79
+ if (liveReporterPath)
80
+ reporters.push(`[${JSON.stringify(liveReporterPath)}]`);
81
+ const reportersStr = `[${reporters.join(", ")}]`;
76
82
  const extraLines = [];
77
83
  if (baseUrl)
78
84
  extraLines.push(` baseURL: ${JSON.stringify(baseUrl)},`);
@@ -87,6 +93,7 @@ function writeConfig(specDir, workers, headless, baseUrl = "", storageState = ""
87
93
  const content = PLAYWRIGHT_CONFIG_TEMPLATE.replace("__TIMEOUT__", String(Math.trunc(testTimeoutMs)))
88
94
  .replace("__WORKERS__", String(workers))
89
95
  .replace("__HEADLESS__", headless ? "true" : "false")
96
+ .replace("__REPORTERS__", reportersStr)
90
97
  .replace("__VIDEO__", retain)
91
98
  .replace("__TRACE__", retain)
92
99
  .replace("__EXTRA_USE__", extraUse);
@@ -7,6 +7,7 @@
7
7
  * server already understands.
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.normalizeStatus = normalizeStatus;
10
11
  exports.parsePlaywrightReport = parsePlaywrightReport;
11
12
  exports.specFilename = specFilename;
12
13
  exports.parseSpecIdentity = parseSpecIdentity;
@@ -17,6 +18,11 @@ const STATUS_MAP = {
17
18
  interrupted: "fail",
18
19
  skipped: "skipped",
19
20
  };
21
+ /** Normalize a raw Playwright test status to the server's vocabulary — shared by
22
+ * the batch report parse and the live per-test reporter stream so both agree. */
23
+ function normalizeStatus(pwStatus) {
24
+ return STATUS_MAP[pwStatus] ?? "fail";
25
+ }
20
26
  // Evidence kind values a Playwright attachment `name` can map to.
21
27
  const ATTACHMENT_KIND_MAP = {
22
28
  screenshot: "screenshot",
@@ -81,11 +81,11 @@ function nodePathEnv(nm) {
81
81
  const existing = process.env.NODE_PATH;
82
82
  return { ...process.env, ...(0, paths_1.childNodeEnv)(), NODE_PATH: existing ? `${nm}${path.delimiter}${existing}` : nm };
83
83
  }
84
- function runProcess(cmd, args, cwd, env, timeoutMs) {
84
+ function runProcess(cmd, args, cwd, env, timeoutMs, onLine) {
85
85
  return new Promise((resolve) => {
86
86
  // No shell: cmd is always an absolute node path (nodeBin()) and args are
87
87
  // absolute script/CLI paths, so this is safe even when paths contain spaces.
88
- const child = (0, child_process_1.spawn)(cmd, args, { cwd, env });
88
+ const child = (0, child_process_1.spawn)(cmd, args, { cwd, env, windowsHide: true });
89
89
  activeChild = child;
90
90
  let stdout = "";
91
91
  let stderr = "";
@@ -99,9 +99,20 @@ function runProcess(cmd, args, cwd, env, timeoutMs) {
99
99
  // Already gone.
100
100
  }
101
101
  }, timeoutMs);
102
- child.stdout?.on("data", (d) => {
103
- stdout += d.toString();
104
- });
102
+ // Read stdout line-by-line so a per-line callback (the live test reporter)
103
+ // can forward results mid-run; still accumulate the full text for diagnostics.
104
+ if (child.stdout) {
105
+ const rl = readline.createInterface({ input: child.stdout });
106
+ rl.on("line", (line) => {
107
+ stdout += line + "\n";
108
+ if (onLine) {
109
+ try {
110
+ onLine(line);
111
+ }
112
+ catch { /* a forwarder must never break the run */ }
113
+ }
114
+ });
115
+ }
105
116
  child.stderr?.on("data", (d) => {
106
117
  stderr += d.toString();
107
118
  });
@@ -136,7 +147,7 @@ async function captureAuth(baseUrl, storageStatePath, sessionStoragePath) {
136
147
  return false;
137
148
  }
138
149
  }
139
- async function runPlaywright(workDir, workers, specFile) {
150
+ async function runPlaywright(workDir, workers, specFile, onLine) {
140
151
  // Invoke Playwright's CLI through the resolved node runtime (`node cli.js test …`)
141
152
  // so the same path works from source, via npx, and in a packaged bundle where
142
153
  // `node`/`node_modules` live beside the executable rather than on PATH.
@@ -144,7 +155,7 @@ async function runPlaywright(workDir, workers, specFile) {
144
155
  if (specFile)
145
156
  args.push(specFile);
146
157
  const nm = (0, paths_1.agentNodeModules)();
147
- return runProcess((0, paths_1.nodeBin)(), args, workDir, nodePathEnv(nm), EXEC_TIMEOUT_MS);
158
+ return runProcess((0, paths_1.nodeBin)(), args, workDir, nodePathEnv(nm), EXEC_TIMEOUT_MS, onLine);
148
159
  }
149
160
  /**
150
161
  * Best identity `{ticket, caseCode}` for wire events: prefers explicit
@@ -245,13 +256,16 @@ async function processJob(cfg, job) {
245
256
  return;
246
257
  }
247
258
  const { storageState, sessionStoragePath } = auth;
248
- (0, playwrightConfig_1.writeConfig)(workDir, job.workers, job.headless, job.baseUrl, storageState);
259
+ (0, playwrightConfig_1.writeConfig)(workDir, job.workers, job.headless, job.baseUrl, storageState, {
260
+ liveReporterPath: (0, paths_1.vendorLiveReporter)(),
261
+ });
249
262
  // Always inject the generated fixtures.ts (DOM capture on every run) + rewrite
250
263
  // spec imports to it. sessionStorage replay (MSAL/SPA tokens) is additionally
251
264
  // enabled only when a manual-auth session actually exists.
252
265
  const replaySession = Boolean(job.manualAuth && storageState && sessionStoragePath && fs.statSync(sessionStoragePath).size > 0);
253
266
  (0, playwrightConfig_1.applyFixtures)(workDir, job.specs.map((s) => s.filename), sessionStoragePath || path.join(workDir, "sessionStorage.json"), replaySession);
254
267
  const total = job.specs.length;
268
+ const specByFile = new Map(job.specs.map((s) => [s.filename, s]));
255
269
  for (let i = 0; i < job.specs.length; i++) {
256
270
  const { ticket, caseCode } = identityFor(job.specs[i]);
257
271
  await api.postEvent(cfg, job.executionId, "exec.case.running", { ticket, caseCode, index: i + 1, total });
@@ -261,8 +275,57 @@ async function processJob(cfg, job) {
261
275
  // action); a multi-case job executes the whole suite — same distinction
262
276
  // as the server's `single_spec`.
263
277
  const singleSpec = job.specs.length === 1 ? job.specs[0].filename : "";
278
+ // Post one case's result + progress. The `handled` guard makes it idempotent
279
+ // per spec, so a result streamed live by the reporter is NOT re-posted by the
280
+ // end-of-run reconcile pass. Counts are reserved synchronously (before the
281
+ // awaits), so they're correct by the time the run ends.
282
+ let passed = 0;
283
+ let failed = 0;
284
+ const handled = new Set();
285
+ const postCaseResult = async (spec, status, durationMs, error) => {
286
+ if (handled.has(spec.filename))
287
+ return;
288
+ handled.add(spec.filename);
289
+ if (status === "pass")
290
+ passed++;
291
+ else if (status === "fail")
292
+ failed++;
293
+ const { ticket, caseCode } = identityFor(spec);
294
+ const progress = total ? Math.trunc((100 * handled.size) / total) : 100;
295
+ await api
296
+ .postResult(cfg, job.executionId, { file: spec.filename, status, duration_ms: durationMs, error_message: error })
297
+ .catch(() => { });
298
+ await api
299
+ .postEvent(cfg, job.executionId, "exec.case.result", { ticket, caseCode, status, durationMs })
300
+ .catch(() => { });
301
+ await api
302
+ .postEvent(cfg, job.executionId, "exec.progress", { progress, passed, failed, remaining: total - handled.size })
303
+ .catch(() => { });
304
+ (0, bus_1.emit)("case-result", { ticket, caseCode, status, durationMs });
305
+ (0, bus_1.emit)("progress", { progress, passed, failed, remaining: total - handled.size });
306
+ };
307
+ // Live streaming (#exec-live): the vendored reporter prints one
308
+ // `QAGENT_TEST {json}` line per finished test, which runProcess feeds here so
309
+ // each spec's status reaches the UI the moment Playwright finishes it — rather
310
+ // than in a burst after the whole run + report.json parse.
311
+ const LIVE_PREFIX = "QAGENT_TEST ";
312
+ const onLine = (line) => {
313
+ if (!line.startsWith(LIVE_PREFIX))
314
+ return;
315
+ let r;
316
+ try {
317
+ r = JSON.parse(line.slice(LIVE_PREFIX.length));
318
+ }
319
+ catch {
320
+ return;
321
+ }
322
+ const spec = specByFile.get(path.basename(String(r.file || "")));
323
+ if (!spec)
324
+ return;
325
+ void postCaseResult(spec, (0, report_1.normalizeStatus)(String(r.status || "")), Math.trunc(Number(r.durationMs) || 0), r.error || "");
326
+ };
264
327
  const started = Date.now();
265
- const { stdout, stderr, timedOut } = await runPlaywright(workDir, job.workers, singleSpec);
328
+ const { stdout, stderr, timedOut } = await runPlaywright(workDir, job.workers, singleSpec, onLine);
266
329
  const elapsedMs = Date.now() - started;
267
330
  const procOutput = [stdout, stderr].filter(Boolean).join("\n").trim();
268
331
  let runError = "";
@@ -284,72 +347,30 @@ async function processJob(cfg, job) {
284
347
  runError = `Playwright produced no report.json${detail}`;
285
348
  }
286
349
  }
287
- let passed = 0;
288
- let failed = 0;
289
- const matched = new Set();
290
- // Evidence uploads are deferred until AFTER results + complete are posted:
291
- // the browser has already closed when a test finishes, so the web should mark
292
- // the case/run done immediately rather than waiting on (potentially multi-MB)
293
- // video/trace/DOM uploads over the network.
350
+ // End-of-run reconcile: gather evidence for every reported spec (from
351
+ // report.json), and post any case the live reporter didn't already stream (a
352
+ // missed test, or a run-error where nothing streamed). Already-streamed specs
353
+ // are skipped by postCaseResult's guard only their evidence is collected.
354
+ // Evidence uploads are deferred until AFTER results + complete are posted so
355
+ // the web marks the run done immediately rather than waiting on (multi-MB)
356
+ // video/trace/DOM uploads.
294
357
  const pendingEvidence = [];
295
358
  for (const spec of job.specs) {
296
359
  const entry = parsed.find((e) => path.basename(e.file) === spec.filename);
297
- if (!entry)
298
- continue;
299
- matched.add(spec.filename);
300
- const durationMs = entry.duration_ms || elapsedMs;
301
- await api.postResult(cfg, job.executionId, {
302
- file: spec.filename,
303
- status: entry.status,
304
- duration_ms: durationMs,
305
- error_message: entry.error_message,
306
- });
307
- if (entry.status === "pass")
308
- passed++;
309
- else if (entry.status === "fail")
310
- failed++;
311
360
  const { ticket, caseCode } = identityFor(spec);
312
- await api.postEvent(cfg, job.executionId, "exec.case.result", {
313
- ticket,
314
- caseCode,
315
- status: entry.status,
316
- durationMs,
317
- });
318
- (0, bus_1.emit)("case-result", { ticket, caseCode, status: entry.status, durationMs });
319
- const progress = total ? Math.trunc((100 * matched.size) / total) : 100;
320
- await api.postEvent(cfg, job.executionId, "exec.progress", {
321
- progress,
322
- passed,
323
- failed,
324
- remaining: total - matched.size,
325
- });
326
- (0, bus_1.emit)("progress", { progress, passed, failed, remaining: total - matched.size });
327
- for (const att of entry.attachments) {
328
- const filePath = path.isAbsolute(att.path) ? att.path : path.join(workDir, att.path);
329
- if (!fs.existsSync(filePath))
330
- continue;
331
- pendingEvidence.push({ ticket, caseCode, kind: att.kind, filePath });
361
+ if (entry) {
362
+ for (const att of entry.attachments) {
363
+ const filePath = path.isAbsolute(att.path) ? att.path : path.join(workDir, att.path);
364
+ if (fs.existsSync(filePath))
365
+ pendingEvidence.push({ ticket, caseCode, kind: att.kind, filePath });
366
+ }
367
+ await postCaseResult(spec, entry.status, entry.duration_ms || elapsedMs, entry.error_message);
368
+ }
369
+ else {
370
+ // No report entry: if it wasn't streamed either, it's a failure (run error
371
+ // / crash). The guard no-ops when the reporter already streamed it.
372
+ await postCaseResult(spec, "fail", elapsedMs, runError || "No result reported by Playwright");
332
373
  }
333
- }
334
- // Any spec Playwright didn't report on (e.g. run_error) is marked failed.
335
- for (const spec of job.specs) {
336
- if (matched.has(spec.filename))
337
- continue;
338
- failed++;
339
- const message = runError || "No result reported by Playwright";
340
- await api.postResult(cfg, job.executionId, {
341
- file: spec.filename,
342
- status: "fail",
343
- duration_ms: elapsedMs,
344
- error_message: message,
345
- });
346
- const { ticket, caseCode } = identityFor(spec);
347
- await api.postEvent(cfg, job.executionId, "exec.case.result", {
348
- ticket,
349
- caseCode,
350
- status: "fail",
351
- durationMs: elapsedMs,
352
- });
353
374
  }
354
375
  // Keep the LAST ~20000 chars so the failing tail survives truncation,
355
376
  // matching the server's own log persistence.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@q-agent/agent",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
4
4
  "description": "Q-Agent Local Agent — claims execution jobs from the Q-Agent server and runs Playwright locally, so manual login/MFA happens on the user's own machine and session credentials never leave it.",
5
5
  "license": "UNLICENSED",
6
6
  "bin": {
@@ -0,0 +1,34 @@
1
+ // Playwright reporter that emits ONE JSON line per finished test to stdout, so
2
+ // the Local Agent can forward each spec's pass/fail to the server the moment it
3
+ // finishes — instead of waiting for the whole run + report.json (#exec-live).
4
+ //
5
+ // The agent tails stdout for lines prefixed with "QAGENT_TEST " and posts
6
+ // exec.case.result immediately. The end-of-run report.json still drives evidence
7
+ // upload + reconciliation, so this reporter only moves the status forward in time.
8
+ //
9
+ // Retries are disabled in the generated config (retries: 0), so onTestEnd fires
10
+ // exactly once per test. Status is emitted RAW (passed/failed/timedOut/…); the
11
+ // agent normalizes it via report.ts's normalizeStatus so both paths agree.
12
+ const path = require('path');
13
+
14
+ class QAgentLiveReporter {
15
+ onTestEnd(test, result) {
16
+ try {
17
+ const file = test && test.location && test.location.file ? path.basename(test.location.file) : '';
18
+ if (!file) return;
19
+ const line =
20
+ 'QAGENT_TEST ' +
21
+ JSON.stringify({
22
+ file,
23
+ status: result && result.status ? result.status : 'failed',
24
+ durationMs: Math.trunc((result && result.duration) || 0),
25
+ error: result && result.error && result.error.message ? String(result.error.message) : '',
26
+ });
27
+ process.stdout.write(line + '\n');
28
+ } catch {
29
+ // A reporter must never throw into the run.
30
+ }
31
+ }
32
+ }
33
+
34
+ module.exports = QAgentLiveReporter;