@q-agent/agent 0.1.0

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.
@@ -0,0 +1,162 @@
1
+ "use strict";
2
+ /**
3
+ * Port of `api/app/services/playwright_runner.py`'s config template
4
+ * (`_PLAYWRIGHT_CONFIG_TEMPLATE` + `_write_config`) and the sessionStorage
5
+ * fixture shim (`_auth_fixtures_ts` + `_apply_auth_fixtures`), reproduced
6
+ * faithfully so a spec that runs on the server and one that runs via the
7
+ * Local Agent behave identically.
8
+ */
9
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ var desc = Object.getOwnPropertyDescriptor(m, k);
12
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
13
+ desc = { enumerable: true, get: function() { return m[k]; } };
14
+ }
15
+ Object.defineProperty(o, k2, desc);
16
+ }) : (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ o[k2] = m[k];
19
+ }));
20
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
21
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
22
+ }) : function(o, v) {
23
+ o["default"] = v;
24
+ });
25
+ var __importStar = (this && this.__importStar) || (function () {
26
+ var ownKeys = function(o) {
27
+ ownKeys = Object.getOwnPropertyNames || function (o) {
28
+ var ar = [];
29
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
30
+ return ar;
31
+ };
32
+ return ownKeys(o);
33
+ };
34
+ return function (mod) {
35
+ if (mod && mod.__esModule) return mod;
36
+ var result = {};
37
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
38
+ __setModuleDefault(result, mod);
39
+ return result;
40
+ };
41
+ })();
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.writeConfig = writeConfig;
44
+ exports.authFixturesTs = authFixturesTs;
45
+ exports.applyAuthFixtures = applyAuthFixtures;
46
+ const fs = __importStar(require("fs"));
47
+ const path = __importStar(require("path"));
48
+ const PLAYWRIGHT_CONFIG_TEMPLATE = `import { defineConfig } from '@playwright/test';
49
+
50
+ export default defineConfig({
51
+ testDir: '.',
52
+ timeout: 30000,
53
+ workers: __WORKERS__,
54
+ reporter: [['json', { outputFile: 'report.json' }]],
55
+ use: {
56
+ headless: __HEADLESS__,
57
+ screenshot: 'only-on-failure',
58
+ video: 'retain-on-failure',
59
+ trace: 'retain-on-failure',
60
+ __EXTRA_USE__ },
61
+ });
62
+ `;
63
+ /**
64
+ * (Re)write `playwright.config.ts` into `specDir` — same shape as the
65
+ * server's `_write_config`.
66
+ *
67
+ * @param specDir The job's local workdir the config is written into.
68
+ * @param workers Parallel worker count.
69
+ * @param headless Whether the browser runs headless.
70
+ * @param baseUrl When non-empty, injected as `use.baseURL`.
71
+ * @param storageState When non-empty, an absolute path injected as `use.storageState`.
72
+ */
73
+ function writeConfig(specDir, workers, headless, baseUrl = "", storageState = "") {
74
+ const extraLines = [];
75
+ if (baseUrl)
76
+ extraLines.push(` baseURL: ${JSON.stringify(baseUrl)},`);
77
+ if (storageState)
78
+ extraLines.push(` storageState: ${JSON.stringify(storageState)},`);
79
+ const extraUse = extraLines.length ? extraLines.join("\n") + "\n" : "";
80
+ const content = PLAYWRIGHT_CONFIG_TEMPLATE.replace("__WORKERS__", String(workers))
81
+ .replace("__HEADLESS__", headless ? "true" : "false")
82
+ .replace("__EXTRA_USE__", extraUse);
83
+ fs.writeFileSync(path.join(specDir, "playwright.config.ts"), content, "utf-8");
84
+ }
85
+ /**
86
+ * TypeScript for a generated `fixtures.ts` that replays sessionStorage —
87
+ * port of `_auth_fixtures_ts`.
88
+ *
89
+ * @param sessionFile Absolute path to the captured `sessionStorage.json`;
90
+ * embedded as a JSON-encoded string literal so the fixture reads it at
91
+ * runtime.
92
+ */
93
+ function authFixturesTs(sessionFile) {
94
+ return ("import { test as base, expect } from '@playwright/test';\n" +
95
+ "import * as fs from 'fs';\n" +
96
+ "\n" +
97
+ `const SESSION_FILE = ${JSON.stringify(sessionFile)};\n` +
98
+ "let SESSIONS: Record<string, Record<string, string>> = {};\n" +
99
+ "try { SESSIONS = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf-8')); } catch {}\n" +
100
+ "\n" +
101
+ "export const test = base.extend({\n" +
102
+ " context: async ({ context }, use) => {\n" +
103
+ " await context.addInitScript((sessions: Record<string, Record<string, string>>) => {\n" +
104
+ " try {\n" +
105
+ " const entries = sessions[location.origin];\n" +
106
+ " if (entries) for (const k in entries) window.sessionStorage.setItem(k, entries[k]);\n" +
107
+ " } catch {}\n" +
108
+ " }, SESSIONS);\n" +
109
+ " await use(context);\n" +
110
+ " },\n" +
111
+ "});\n" +
112
+ "\n" +
113
+ "export { expect };\n" +
114
+ "export type * from '@playwright/test';\n");
115
+ }
116
+ /**
117
+ * Rewrite each spec's Playwright import to use (or stop using) the
118
+ * sessionStorage fixture — port of `_apply_auth_fixtures`. Only the module
119
+ * specifier is touched.
120
+ *
121
+ * The Python version globs `*.spec.ts` in `specDir`; the agent already
122
+ * knows exactly which spec filenames it wrote for this job, so
123
+ * `specFilenames` is passed explicitly instead of re-scanning the
124
+ * directory (equivalent behavior, no extra glob dependency).
125
+ *
126
+ * @param specDir The job's local workdir containing the spec files.
127
+ * @param specFilenames The spec filenames written into `specDir` for this job.
128
+ * @param sessionFile Absolute path to the `sessionStorage.json` snapshot embedded
129
+ * in the generated `fixtures.ts`.
130
+ * @param enabled Whether sessionStorage replay is active for this run.
131
+ */
132
+ function applyAuthFixtures(specDir, specFilenames, sessionFile, enabled) {
133
+ const replacements = enabled
134
+ ? [
135
+ ["'@playwright/test'", "'./fixtures'"],
136
+ ['"@playwright/test"', '"./fixtures"'],
137
+ ]
138
+ : [
139
+ ["'./fixtures'", "'@playwright/test'"],
140
+ ['"./fixtures"', '"@playwright/test"'],
141
+ ];
142
+ for (const filename of specFilenames) {
143
+ const specPath = path.join(specDir, filename);
144
+ let text;
145
+ try {
146
+ text = fs.readFileSync(specPath, "utf-8");
147
+ }
148
+ catch {
149
+ continue;
150
+ }
151
+ let newText = text;
152
+ for (const [oldStr, newStr] of replacements) {
153
+ newText = newText.split(oldStr).join(newStr);
154
+ }
155
+ if (newText !== text) {
156
+ fs.writeFileSync(specPath, newText, "utf-8");
157
+ }
158
+ }
159
+ if (enabled) {
160
+ fs.writeFileSync(path.join(specDir, "fixtures.ts"), authFixturesTs(sessionFile), "utf-8");
161
+ }
162
+ }
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ /**
3
+ * Port of `api/app/services/playwright_runner.py`'s `parse_playwright_report`
4
+ * (and the `spec_service.spec_filename` convention it depends on) — kept
5
+ * byte-for-byte faithful to the Python behavior so a report produced by the
6
+ * agent's local Playwright run maps to the exact same per-spec shape the
7
+ * server already understands.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.parsePlaywrightReport = parsePlaywrightReport;
11
+ exports.specFilename = specFilename;
12
+ exports.parseSpecIdentity = parseSpecIdentity;
13
+ const STATUS_MAP = {
14
+ passed: "pass",
15
+ failed: "fail",
16
+ timedOut: "fail",
17
+ interrupted: "fail",
18
+ skipped: "skipped",
19
+ };
20
+ // Evidence kind values a Playwright attachment `name` can map to.
21
+ const ATTACHMENT_KIND_MAP = {
22
+ screenshot: "screenshot",
23
+ video: "video",
24
+ trace: "trace",
25
+ };
26
+ /* eslint-enable @typescript-eslint/no-explicit-any */
27
+ /**
28
+ * Map a Playwright JSON-reporter report to per-spec result dicts.
29
+ *
30
+ * Nested suites (Playwright groups specs per file, and can nest describe
31
+ * blocks) are flattened. If a test was retried, the LAST result is used to
32
+ * determine final status/duration/error, matching what the Playwright UI
33
+ * reports as the outcome — same semantics as the Python `parse_playwright_report`.
34
+ *
35
+ * @param report Parsed contents of Playwright's `--reporter=json` output.
36
+ * @returns One entry per test spec.
37
+ */
38
+ function parsePlaywrightReport(report) {
39
+ const out = [];
40
+ function walk(suite, fileHint) {
41
+ const fileName = suite.file || fileHint;
42
+ for (const spec of suite.specs || []) {
43
+ const specFile = spec.file || fileName;
44
+ for (const test of spec.tests || []) {
45
+ const results = test.results || [];
46
+ const last = results.length ? results[results.length - 1] : {};
47
+ const status = STATUS_MAP[last.status || ""] ?? "fail";
48
+ const error = last.error;
49
+ const errorMessage = error && typeof error === "object" ? error.message || "" : typeof error === "string" ? error : "";
50
+ const attachments = (last.attachments || [])
51
+ .filter((a) => a.name !== undefined && a.name in ATTACHMENT_KIND_MAP && !!a.path)
52
+ .map((a) => ({ kind: ATTACHMENT_KIND_MAP[a.name], path: a.path }));
53
+ out.push({
54
+ file: specFile,
55
+ title: spec.title || "",
56
+ status,
57
+ duration_ms: Math.trunc(last.duration || 0),
58
+ error_message: errorMessage,
59
+ attachments,
60
+ });
61
+ }
62
+ }
63
+ for (const child of suite.suites || []) {
64
+ walk(child, fileName);
65
+ }
66
+ }
67
+ for (const topSuite of report.suites || []) {
68
+ walk(topSuite, topSuite.file || "");
69
+ }
70
+ return out;
71
+ }
72
+ /**
73
+ * Build the on-disk spec filename for a case — port of
74
+ * `spec_service.spec_filename`.
75
+ *
76
+ * @param ticketExternalId e.g. "SUR-1428".
77
+ * @param caseCode e.g. "TC-01".
78
+ * @returns A filename like "1428-TC-01.spec.ts".
79
+ */
80
+ function specFilename(ticketExternalId, caseCode) {
81
+ const parts = ticketExternalId.split("-");
82
+ const shortTicket = parts[parts.length - 1];
83
+ return `${shortTicket}-${caseCode}.spec.ts`;
84
+ }
85
+ /**
86
+ * Best-effort reverse of `specFilename`: recover `{shortTicket, caseCode}`
87
+ * from a filename, assuming the fixed `TC-NN` case-code convention used
88
+ * everywhere else in this codebase (`ai_service.py` `_next_case_code`).
89
+ *
90
+ * NOTE: this only recovers the short numeric suffix of the ticket id (e.g.
91
+ * "1428"), not its full provider-prefixed external id (e.g. "SUR-1428") —
92
+ * the `/agent/jobs/next` payload does not currently include that (see
93
+ * README "Known limitation"). Callers should prefer explicit
94
+ * `ticketExternalId`/`caseCode` fields on the job spec when present, and
95
+ * only fall back to this parse otherwise.
96
+ *
97
+ * @param filename e.g. "1428-TC-01.spec.ts".
98
+ */
99
+ function parseSpecIdentity(filename) {
100
+ const base = filename.replace(/\.spec\.ts$/, "");
101
+ const match = /^(.+)-(TC-\d+)$/.exec(base);
102
+ if (match) {
103
+ return { shortTicket: match[1], caseCode: match[2] };
104
+ }
105
+ return { shortTicket: base, caseCode: "" };
106
+ }
@@ -0,0 +1,396 @@
1
+ "use strict";
2
+ /**
3
+ * The job loop: claim a queued execution, run it locally with Playwright,
4
+ * and push progress/results/evidence back to the server. Faithful port of
5
+ * `api/app/services/playwright_runner.py`'s `run_execution`, with DB writes
6
+ * and `hub.publish` WS events replaced by the `/agent/jobs/*` HTTP calls
7
+ * (`api.ts`).
8
+ */
9
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ var desc = Object.getOwnPropertyDescriptor(m, k);
12
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
13
+ desc = { enumerable: true, get: function() { return m[k]; } };
14
+ }
15
+ Object.defineProperty(o, k2, desc);
16
+ }) : (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ o[k2] = m[k];
19
+ }));
20
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
21
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
22
+ }) : function(o, v) {
23
+ o["default"] = v;
24
+ });
25
+ var __importStar = (this && this.__importStar) || (function () {
26
+ var ownKeys = function(o) {
27
+ ownKeys = Object.getOwnPropertyNames || function (o) {
28
+ var ar = [];
29
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
30
+ return ar;
31
+ };
32
+ return ownKeys(o);
33
+ };
34
+ return function (mod) {
35
+ if (mod && mod.__esModule) return mod;
36
+ var result = {};
37
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
38
+ __setModuleDefault(result, mod);
39
+ return result;
40
+ };
41
+ })();
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.killActiveChild = killActiveChild;
44
+ exports.processJob = processJob;
45
+ exports.runAgentLoop = runAgentLoop;
46
+ const child_process_1 = require("child_process");
47
+ const fs = __importStar(require("fs"));
48
+ const os = __importStar(require("os"));
49
+ const path = __importStar(require("path"));
50
+ const api = __importStar(require("./api"));
51
+ const ensureBrowser_1 = require("./ensureBrowser");
52
+ const playwrightConfig_1 = require("./playwrightConfig");
53
+ const report_1 = require("./report");
54
+ const session_1 = require("./session");
55
+ // Mirrors api/app/config.py's Settings.exec_timeout_s / auth_capture_timeout_s.
56
+ const EXEC_TIMEOUT_MS = 600_000;
57
+ const AUTH_CAPTURE_TIMEOUT_MS = 300_000;
58
+ const IDLE_POLL_MS = 3_000;
59
+ let activeChild = null;
60
+ /** Kill whatever child process (capture browser or Playwright run) is active. Used by the CLI's SIGINT handler. */
61
+ function killActiveChild() {
62
+ if (activeChild && !activeChild.killed) {
63
+ try {
64
+ activeChild.kill();
65
+ }
66
+ catch {
67
+ // Already gone.
68
+ }
69
+ }
70
+ }
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
+ function nodePathEnv(nm) {
94
+ const existing = process.env.NODE_PATH;
95
+ return { ...process.env, NODE_PATH: existing ? `${nm}${path.delimiter}${existing}` : nm };
96
+ }
97
+ function runProcess(cmd, args, cwd, env, timeoutMs) {
98
+ return new Promise((resolve) => {
99
+ const child = (0, child_process_1.spawn)(cmd, args, { cwd, env, shell: process.platform === "win32" });
100
+ activeChild = child;
101
+ let stdout = "";
102
+ let stderr = "";
103
+ let timedOut = false;
104
+ const timer = setTimeout(() => {
105
+ timedOut = true;
106
+ try {
107
+ child.kill();
108
+ }
109
+ catch {
110
+ // Already gone.
111
+ }
112
+ }, timeoutMs);
113
+ child.stdout?.on("data", (d) => {
114
+ stdout += d.toString();
115
+ });
116
+ child.stderr?.on("data", (d) => {
117
+ stderr += d.toString();
118
+ });
119
+ const finish = (code) => {
120
+ clearTimeout(timer);
121
+ if (activeChild === child)
122
+ activeChild = null;
123
+ resolve({ code, stdout, stderr, timedOut });
124
+ };
125
+ child.on("close", finish);
126
+ child.on("error", () => finish(null));
127
+ });
128
+ }
129
+ /**
130
+ * Open a HEADED browser for manual login (port of
131
+ * `playwright_runner.py`'s `_capture_once`, vendored verbatim as
132
+ * `vendor/capture_auth.cjs`). Returns true only once `storageStatePath` is
133
+ * non-empty after the browser closes.
134
+ */
135
+ async function captureAuth(baseUrl, storageStatePath, sessionStoragePath) {
136
+ fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
137
+ const script = vendorCaptureScript();
138
+ const nm = agentNodeModules();
139
+ const result = await runProcess("node", [script, baseUrl, storageStatePath, sessionStoragePath], nm, nodePathEnv(nm), AUTH_CAPTURE_TIMEOUT_MS);
140
+ if (result.code !== 0) {
141
+ console.error(`Auth capture exited ${result.code}: ${(result.stderr || result.stdout || "").slice(0, 800)}`);
142
+ }
143
+ try {
144
+ return fs.statSync(storageStatePath).size > 0;
145
+ }
146
+ catch {
147
+ return false;
148
+ }
149
+ }
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
+ async function runPlaywright(workDir, workers, specFile) {
159
+ const { cmd, baseArgs } = playwrightCommand();
160
+ const args = [...baseArgs, `--workers=${workers}`];
161
+ if (specFile)
162
+ args.push(specFile);
163
+ const nm = agentNodeModules();
164
+ return runProcess(cmd, args, workDir, nodePathEnv(nm), EXEC_TIMEOUT_MS);
165
+ }
166
+ /**
167
+ * Best identity `{ticket, caseCode}` for wire events: prefers explicit
168
+ * fields on the job spec (forward-compatible with a server patch that adds
169
+ * them), falling back to parsing the filename convention otherwise. See
170
+ * README "Known limitation" — the fallback only recovers the ticket's
171
+ * short numeric suffix, not its full provider-prefixed external id.
172
+ */
173
+ function identityFor(spec) {
174
+ if (spec.ticketExternalId && spec.caseCode) {
175
+ return { ticket: spec.ticketExternalId, caseCode: spec.caseCode };
176
+ }
177
+ const parsed = (0, report_1.parseSpecIdentity)(spec.filename);
178
+ return { ticket: spec.ticketExternalId || parsed.shortTicket, caseCode: spec.caseCode || parsed.caseCode };
179
+ }
180
+ /** Mark every spec in the job failed with `message` and finalize — used when a run cannot proceed (e.g. manual login was not completed). */
181
+ async function failAllResults(cfg, job, message) {
182
+ for (const spec of job.specs) {
183
+ const { ticket, caseCode } = identityFor(spec);
184
+ await api
185
+ .postResult(cfg, job.executionId, { file: spec.filename, status: "fail", duration_ms: 0, error_message: message })
186
+ .catch((err) => console.error("postResult failed:", err));
187
+ await api
188
+ .postEvent(cfg, job.executionId, "exec.case.result", { ticket, caseCode, status: "fail", durationMs: 0 })
189
+ .catch((err) => console.error("postEvent failed:", err));
190
+ }
191
+ const total = job.specs.length;
192
+ await api.postComplete(cfg, job.executionId, { passed: 0, failed: total, log: message }).catch((err) => console.error("postComplete failed:", err));
193
+ }
194
+ /** Process one claimed job end-to-end: write specs/config, resolve auth, run Playwright, push results. */
195
+ async function processJob(cfg, job) {
196
+ const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "qagent-"));
197
+ try {
198
+ for (const spec of job.specs) {
199
+ fs.writeFileSync(path.join(workDir, spec.filename), spec.code, "utf-8");
200
+ }
201
+ // ---- Resolve auth: reuse a valid local session, or capture one headed.
202
+ let storageState = "";
203
+ let sessionStoragePath = "";
204
+ if (job.manualAuth) {
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
+ }
241
+ }
242
+ (0, playwrightConfig_1.writeConfig)(workDir, job.workers, job.headless, job.baseUrl, storageState);
243
+ // Replay captured sessionStorage (MSAL/SPA tokens) only when a manual-auth
244
+ // session actually exists; otherwise normalize spec imports back to
245
+ // '@playwright/test' (mirrors `_apply_auth_fixtures`'s `use_fixtures` gate).
246
+ const useFixtures = Boolean(job.manualAuth && storageState && sessionStoragePath && fs.statSync(sessionStoragePath).size > 0);
247
+ (0, playwrightConfig_1.applyAuthFixtures)(workDir, job.specs.map((s) => s.filename), sessionStoragePath || path.join(workDir, "sessionStorage.json"), useFixtures);
248
+ const total = job.specs.length;
249
+ for (let i = 0; i < job.specs.length; i++) {
250
+ const { ticket, caseCode } = identityFor(job.specs[i]);
251
+ await api.postEvent(cfg, job.executionId, "exec.case.running", { ticket, caseCode, index: i + 1, total });
252
+ }
253
+ // A single-spec job targets just that one file (the "run this test"
254
+ // action); a multi-case job executes the whole suite — same distinction
255
+ // as the server's `single_spec`.
256
+ const singleSpec = job.specs.length === 1 ? job.specs[0].filename : "";
257
+ const started = Date.now();
258
+ const { stdout, stderr, timedOut } = await runPlaywright(workDir, job.workers, singleSpec);
259
+ const elapsedMs = Date.now() - started;
260
+ const procOutput = [stdout, stderr].filter(Boolean).join("\n").trim();
261
+ let runError = "";
262
+ if (timedOut)
263
+ runError = `Playwright run timed out after ${EXEC_TIMEOUT_MS / 1000}s`;
264
+ const reportPath = path.join(workDir, "report.json");
265
+ let parsed = [];
266
+ if (!runError) {
267
+ if (fs.existsSync(reportPath)) {
268
+ try {
269
+ parsed = (0, report_1.parsePlaywrightReport)(JSON.parse(fs.readFileSync(reportPath, "utf-8")));
270
+ }
271
+ catch (exc) {
272
+ runError = `Could not parse Playwright report: ${exc.message}`;
273
+ }
274
+ }
275
+ else {
276
+ const detail = procOutput ? ` — ${procOutput.slice(0, 600)}` : "";
277
+ runError = `Playwright produced no report.json${detail}`;
278
+ }
279
+ }
280
+ let passed = 0;
281
+ let failed = 0;
282
+ const matched = new Set();
283
+ for (const spec of job.specs) {
284
+ const entry = parsed.find((e) => path.basename(e.file) === spec.filename);
285
+ if (!entry)
286
+ continue;
287
+ matched.add(spec.filename);
288
+ const durationMs = entry.duration_ms || elapsedMs;
289
+ await api.postResult(cfg, job.executionId, {
290
+ file: spec.filename,
291
+ status: entry.status,
292
+ duration_ms: durationMs,
293
+ error_message: entry.error_message,
294
+ });
295
+ if (entry.status === "pass")
296
+ passed++;
297
+ else if (entry.status === "fail")
298
+ 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
+ const { ticket, caseCode } = identityFor(spec);
315
+ await api.postEvent(cfg, job.executionId, "exec.case.result", {
316
+ ticket,
317
+ caseCode,
318
+ status: entry.status,
319
+ durationMs,
320
+ });
321
+ const progress = total ? Math.trunc((100 * matched.size) / total) : 100;
322
+ await api.postEvent(cfg, job.executionId, "exec.progress", {
323
+ progress,
324
+ passed,
325
+ failed,
326
+ remaining: total - matched.size,
327
+ });
328
+ }
329
+ // Any spec Playwright didn't report on (e.g. run_error) is marked failed.
330
+ for (const spec of job.specs) {
331
+ if (matched.has(spec.filename))
332
+ continue;
333
+ failed++;
334
+ const message = runError || "No result reported by Playwright";
335
+ await api.postResult(cfg, job.executionId, {
336
+ file: spec.filename,
337
+ status: "fail",
338
+ duration_ms: elapsedMs,
339
+ error_message: message,
340
+ });
341
+ const { ticket, caseCode } = identityFor(spec);
342
+ await api.postEvent(cfg, job.executionId, "exec.case.result", {
343
+ ticket,
344
+ caseCode,
345
+ status: "fail",
346
+ durationMs: elapsedMs,
347
+ });
348
+ }
349
+ // Keep the LAST ~20000 chars so the failing tail survives truncation,
350
+ // matching the server's own log persistence.
351
+ const logText = (procOutput || runError || "").slice(-20000);
352
+ await api.postComplete(cfg, job.executionId, { passed, failed, log: logText });
353
+ }
354
+ finally {
355
+ fs.rmSync(workDir, { recursive: true, force: true });
356
+ }
357
+ }
358
+ /**
359
+ * Long-poll loop: claim → process → repeat, backing off `IDLE_POLL_MS`
360
+ * between empty claims. Runs until `signal.aborted`.
361
+ */
362
+ async function runAgentLoop(cfg, signal) {
363
+ if (!(await (0, ensureBrowser_1.ensureChromium)())) {
364
+ console.error("Chromium is required to run tests — aborting.");
365
+ return;
366
+ }
367
+ console.log(`Local Agent started — polling ${cfg.serverUrl} as device #${cfg.deviceId} (${cfg.deviceName})`);
368
+ while (!signal.aborted) {
369
+ let job = null;
370
+ try {
371
+ job = await api.claimNextJob(cfg);
372
+ }
373
+ catch (err) {
374
+ console.error("Claim failed:", err.message);
375
+ }
376
+ if (!job) {
377
+ await new Promise((r) => setTimeout(r, IDLE_POLL_MS));
378
+ continue;
379
+ }
380
+ console.log(`Claimed execution #${job.executionId} (run ${job.runCode}, ${job.specs.length} spec(s))`);
381
+ try {
382
+ await processJob(cfg, job);
383
+ console.log(`Execution #${job.executionId} complete`);
384
+ }
385
+ catch (err) {
386
+ console.error(`Execution #${job.executionId} crashed:`, err);
387
+ await api
388
+ .postComplete(cfg, job.executionId, {
389
+ passed: 0,
390
+ failed: job.specs.length,
391
+ log: `Local Agent crashed: ${err.message}`,
392
+ })
393
+ .catch(() => { });
394
+ }
395
+ }
396
+ }