@q-agent/agent 0.1.22 → 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 +14 -0
- package/dist/src/playwrightConfig.js +9 -2
- package/dist/src/report.js +6 -0
- package/dist/src/runner.js +91 -70
- package/package.json +1 -1
- package/vendor/live_reporter.cjs +34 -0
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:
|
|
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);
|
package/dist/src/report.js
CHANGED
|
@@ -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",
|
package/dist/src/runner.js
CHANGED
|
@@ -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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
// the
|
|
293
|
-
// video/trace/DOM uploads
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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.
|
|
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;
|