@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 CHANGED
@@ -17,7 +17,18 @@ Chromium is installed automatically: the first time you run `qagent-agent start`
17
17
  the agent downloads Playwright's Chromium if it isn't already present (one-time,
18
18
  ~100 MB, with visible progress). No manual `npx playwright install` step is needed.
19
19
 
20
- ## Install & run
20
+ ## Run with the UI (easiest)
21
+
22
+ ```bash
23
+ npx @q-agent/agent
24
+ ```
25
+
26
+ Running with no command opens a local web UI in your browser. Paste a pairing
27
+ code (from the Q-Agent app's **Local Agent** screen) and the server URL, click
28
+ **Connect**, and watch the pull/run progress live. The device token is stored
29
+ locally, so next time it reconnects and starts pulling automatically.
30
+
31
+ ## Command-line
21
32
 
22
33
  ```bash
23
34
  npx @q-agent/agent pair <code> --server https://your-qagent-server.example.com/api
@@ -62,6 +73,22 @@ Chromium still auto-installs on first `start`. Build the bundle on Windows with
62
73
  Node 20+ that supports SEA (the local `node.exe` is used as the executable base —
63
74
  no compiler or download needed).
64
75
 
76
+ ## Desktop app (Electron)
77
+
78
+ A native window that wraps the same web UI:
79
+
80
+ ```bash
81
+ cd agent
82
+ npm install
83
+ npm run desktop # dev: builds, then opens the app window
84
+ npm run dist:desktop # build a Windows installer (electron-builder → dist-bin/desktop/)
85
+ ```
86
+
87
+ The Electron shell runs the agent's UI server in-process and shows it in a
88
+ window — pair by entering the 6-digit code + server URL, then watch progress.
89
+ Child processes (Playwright / login capture) run via Electron-as-Node
90
+ (`ELECTRON_RUN_AS_NODE`), so no separate Node install is needed.
91
+
65
92
  ## Commands
66
93
 
67
94
  | Command | Description |
package/dist/src/api.js CHANGED
@@ -40,11 +40,17 @@ var __importStar = (this && this.__importStar) || (function () {
40
40
  })();
41
41
  Object.defineProperty(exports, "__esModule", { value: true });
42
42
  exports.ApiError = void 0;
43
+ exports.fetchWithTimeout = fetchWithTimeout;
43
44
  exports.redeemDevice = redeemDevice;
45
+ exports.disconnectDevice = disconnectDevice;
44
46
  exports.claimNextJob = claimNextJob;
47
+ exports.claimNextCapture = claimNextCapture;
48
+ exports.postCaptureComplete = postCaptureComplete;
45
49
  exports.postEvent = postEvent;
46
50
  exports.postResult = postResult;
47
51
  exports.postEvidence = postEvidence;
52
+ exports.postHealFix = postHealFix;
53
+ exports.postHealFinalize = postHealFinalize;
48
54
  exports.postComplete = postComplete;
49
55
  const fs = __importStar(require("fs"));
50
56
  /** Raised for any non-2xx/204 response; carries the HTTP status for callers that care (e.g. 409). */
@@ -66,6 +72,22 @@ async function throwIfNotOk(res) {
66
72
  throw new ApiError(res.status, await res.text().catch(() => ""));
67
73
  }
68
74
  }
75
+ /** Multipart evidence (video/trace) can be multi-MB; cap the upload so a stalled
76
+ * connection aborts instead of hanging the background uploader forever. */
77
+ const EVIDENCE_UPLOAD_TIMEOUT_MS = 300_000;
78
+ /** `fetch()` with a hard timeout via `AbortController`, so a stalled connection
79
+ * can't hang indefinitely (Node's `fetch` has no default timeout). Rejects with
80
+ * an `AbortError` once `timeoutMs` elapses. */
81
+ async function fetchWithTimeout(url, init, timeoutMs) {
82
+ const controller = new AbortController();
83
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
84
+ try {
85
+ return await fetch(url, { ...init, signal: controller.signal });
86
+ }
87
+ finally {
88
+ clearTimeout(timer);
89
+ }
90
+ }
69
91
  /** Redeem a one-time pairing code for a durable device token (the `pair` command). */
70
92
  async function redeemDevice(serverUrl, code, name) {
71
93
  const res = await fetch(`${serverUrl}/agent/devices/redeem`, {
@@ -76,6 +98,16 @@ async function redeemDevice(serverUrl, code, name) {
76
98
  await throwIfNotOk(res);
77
99
  return (await res.json());
78
100
  }
101
+ /** Self-revoke this device on the server (the "Disconnect" action). Best-effort:
102
+ * the caller wipes the local token regardless, so a failure here only means the
103
+ * server list clears on `last_seen_at` staleness instead of immediately. */
104
+ async function disconnectDevice(cfg) {
105
+ const res = await fetch(`${cfg.serverUrl}/agent/disconnect`, {
106
+ method: "POST",
107
+ headers: authHeaders(cfg.deviceToken),
108
+ });
109
+ await throwIfNotOk(res);
110
+ }
79
111
  /** Long-poll claim the next queued job for this device's owner. `null` on 204 (nothing queued). */
80
112
  async function claimNextJob(cfg) {
81
113
  const res = await fetch(`${cfg.serverUrl}/agent/jobs/next`, {
@@ -87,6 +119,26 @@ async function claimNextJob(cfg) {
87
119
  await throwIfNotOk(res);
88
120
  return (await res.json());
89
121
  }
122
+ /** Claim the next queued auth-capture for this device's owner. `null` on 204. */
123
+ async function claimNextCapture(cfg) {
124
+ const res = await fetch(`${cfg.serverUrl}/agent/auth/next`, {
125
+ method: "POST",
126
+ headers: authHeaders(cfg.deviceToken),
127
+ });
128
+ if (res.status === 204)
129
+ return null;
130
+ await throwIfNotOk(res);
131
+ return (await res.json());
132
+ }
133
+ /** Report a capture's outcome so the server clears "capturing" + stamps the marker. */
134
+ async function postCaptureComplete(cfg, captureId, ok, error) {
135
+ const res = await fetch(`${cfg.serverUrl}/agent/auth/${captureId}/complete`, {
136
+ method: "POST",
137
+ headers: { ...authHeaders(cfg.deviceToken), "Content-Type": "application/json" },
138
+ body: JSON.stringify({ ok, error }),
139
+ });
140
+ await throwIfNotOk(res);
141
+ }
90
142
  /** Push one progress event; the server re-emits it on the run's WebSocket unchanged. */
91
143
  async function postEvent(cfg, executionId, event, payload) {
92
144
  const res = await fetch(`${cfg.serverUrl}/agent/jobs/${executionId}/events`, {
@@ -113,10 +165,54 @@ async function postEvidence(cfg, executionId, ev) {
113
165
  form.set("case_code", ev.caseCode);
114
166
  form.set("kind", ev.kind);
115
167
  form.set("file", new Blob([data]), ev.filename);
116
- const res = await fetch(`${cfg.serverUrl}/agent/jobs/${executionId}/evidence`, {
168
+ const res = await fetchWithTimeout(`${cfg.serverUrl}/agent/jobs/${executionId}/evidence`, {
117
169
  method: "POST",
118
170
  headers: authHeaders(cfg.deviceToken),
119
171
  body: form,
172
+ }, EVIDENCE_UPLOAD_TIMEOUT_MS);
173
+ await throwIfNotOk(res);
174
+ }
175
+ /** Heal-fix polling: the server generates the fix (~3 min Claude call) in the
176
+ * background so no single request stays open long enough to hit a fronting
177
+ * proxy's ~100s edge cap (Cloudflare 524). Poll every 3s, capped at 6 min
178
+ * (covers the server's 300s Claude timeout + margin); each request is short. */
179
+ const HEAL_FIX_POLL_INTERVAL_MS = 3_000;
180
+ const HEAL_FIX_TOTAL_TIMEOUT_MS = 360_000;
181
+ const HEAL_FIX_REQUEST_TIMEOUT_MS = 30_000;
182
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
183
+ /** Ask the server to classify + fix one failed heal attempt (Claude + KB live
184
+ * server-side). Starts an async job then polls for the result, so a long fix
185
+ * generation never trips the ~100s proxy timeout that a synchronous call did. */
186
+ async function postHealFix(cfg, caseId, body) {
187
+ const startRes = await fetchWithTimeout(`${cfg.serverUrl}/agent/heal/${caseId}/fix`, {
188
+ method: "POST",
189
+ headers: { ...authHeaders(cfg.deviceToken), "Content-Type": "application/json" },
190
+ body: JSON.stringify(body),
191
+ }, HEAL_FIX_REQUEST_TIMEOUT_MS);
192
+ await throwIfNotOk(startRes);
193
+ const { jobId } = (await startRes.json());
194
+ if (!jobId)
195
+ throw new Error("Heal fix did not return a job id");
196
+ const deadline = Date.now() + HEAL_FIX_TOTAL_TIMEOUT_MS;
197
+ for (;;) {
198
+ const res = await fetchWithTimeout(`${cfg.serverUrl}/agent/heal/${caseId}/fix/${jobId}`, { method: "GET", headers: authHeaders(cfg.deviceToken) }, HEAL_FIX_REQUEST_TIMEOUT_MS);
199
+ await throwIfNotOk(res);
200
+ const data = (await res.json());
201
+ if (data.status === "done" && data.result)
202
+ return data.result;
203
+ if (data.status === "error")
204
+ throw new Error(data.error || "Heal fix failed on the server");
205
+ if (Date.now() > deadline)
206
+ throw new Error("Heal fix timed out waiting for the server");
207
+ await sleep(HEAL_FIX_POLL_INTERVAL_MS);
208
+ }
209
+ }
210
+ /** Persist the heal's final outcome + feed a passing DOM-grounded heal into the KB. */
211
+ async function postHealFinalize(cfg, caseId, body) {
212
+ const res = await fetch(`${cfg.serverUrl}/agent/heal/${caseId}/finalize`, {
213
+ method: "POST",
214
+ headers: { ...authHeaders(cfg.deviceToken), "Content-Type": "application/json" },
215
+ body: JSON.stringify(body),
120
216
  });
121
217
  await throwIfNotOk(res);
122
218
  }
package/dist/src/cli.js CHANGED
@@ -48,21 +48,28 @@ const os = __importStar(require("os"));
48
48
  const config_1 = require("./config");
49
49
  const api_1 = require("./api");
50
50
  const runner_1 = require("./runner");
51
+ const ui_1 = require("./ui");
52
+ const version_1 = require("./version");
51
53
  /** How the user invoked this agent, for accurate "run X" hints: the bare command
52
54
  * inside a packaged bundle, else the `npx` form of the published package. */
53
55
  function invocation() {
54
56
  return process.pkg ? "qagent-agent" : "npx @q-agent/agent";
55
57
  }
56
58
  const program = new commander_1.Command();
57
- program.name("qagent-agent").description("Q-Agent Local Agent — runs Playwright locally for this machine's owner.");
59
+ program
60
+ .name("qagent-agent")
61
+ .description("Q-Agent Local Agent — runs Playwright locally for this machine's owner.")
62
+ // Surfaces the actual installed build (e.g. via `npx @q-agent/agent --version`),
63
+ // so a stale npx cache serving an old agent is easy to spot.
64
+ .version((0, version_1.agentVersion)(), "-v, --version", "print the agent version");
58
65
  program
59
66
  .command("pair")
60
67
  .description("Redeem a one-time pairing code (from the Q-Agent SPA) for a device token")
61
68
  .argument("<code>", "pairing code shown in the SPA")
62
- .option("--server <url>", "Q-Agent server base URL", "http://127.0.0.1:8787")
69
+ .option("--server <url>", "Q-Agent server base URL (defaults to the server baked into this build)")
63
70
  .option("--name <name>", "device name shown in the paired-devices list", os.hostname())
64
71
  .action(async (code, opts) => {
65
- const serverUrl = opts.server.replace(/\/+$/, "");
72
+ const serverUrl = (opts.server || (0, config_1.defaultServerUrl)() || "http://127.0.0.1:8787").replace(/\/+$/, "");
66
73
  try {
67
74
  const { deviceToken, deviceId } = await (0, api_1.redeemDevice)(serverUrl, code, opts.name);
68
75
  const cfg = { serverUrl, deviceToken, deviceId, deviceName: opts.name };
@@ -117,6 +124,14 @@ program
117
124
  (0, config_1.clearConfig)();
118
125
  console.log(`Device token cleared. Run \`${invocation()} pair <code>\` to pair again.`);
119
126
  });
127
+ program
128
+ .command("ui", { isDefault: true })
129
+ .description("Open the local web UI to pair and watch progress in your browser (default)")
130
+ .option("--port <port>", "port for the local UI server (default 7420)")
131
+ .option("--no-open", "start the UI server without opening a browser")
132
+ .action((opts) => {
133
+ (0, ui_1.startUi)({ port: opts.port ? Number(opts.port) : undefined, open: opts.open !== false });
134
+ });
120
135
  program.parseAsync(process.argv).catch((err) => {
121
136
  console.error(err);
122
137
  process.exitCode = 1;
@@ -38,6 +38,7 @@ var __importStar = (this && this.__importStar) || (function () {
38
38
  };
39
39
  })();
40
40
  Object.defineProperty(exports, "__esModule", { value: true });
41
+ exports.defaultServerUrl = defaultServerUrl;
41
42
  exports.configDir = configDir;
42
43
  exports.loadConfig = loadConfig;
43
44
  exports.saveConfig = saveConfig;
@@ -45,6 +46,30 @@ exports.clearConfig = clearConfig;
45
46
  const fs = __importStar(require("fs"));
46
47
  const os = __importStar(require("os"));
47
48
  const path = __importStar(require("path"));
49
+ /** The server URL baked into this build, so the packaged desktop app can
50
+ * pre-fill it and the user only types the 6-digit code (no URL to enter).
51
+ *
52
+ * Resolution: a runtime ``QAGENT_SERVER`` env override wins (dev/testing), then
53
+ * the ``qagentServer`` field electron-builder merges into the packaged
54
+ * ``package.json`` (see build.extraMetadata). Empty for the generic npm/npx
55
+ * build — those callers still pass ``--server`` (or type the URL) as before. */
56
+ function defaultServerUrl() {
57
+ const fromEnv = (process.env.QAGENT_SERVER || "").trim();
58
+ if (fromEnv)
59
+ return fromEnv.replace(/\/+$/, "");
60
+ try {
61
+ // dist/src/config.js → ../../package.json (the app's own manifest, which in
62
+ // a packaged build carries the baked-in qagentServer).
63
+ const pkgPath = path.join(__dirname, "..", "..", "package.json");
64
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
65
+ if (typeof pkg.qagentServer === "string")
66
+ return pkg.qagentServer.trim().replace(/\/+$/, "");
67
+ }
68
+ catch {
69
+ // Generic build / manifest unreadable — no baked server.
70
+ }
71
+ return "";
72
+ }
48
73
  /** Directory holding the agent's persisted config + local session store. */
49
74
  function configDir() {
50
75
  return path.join(os.homedir(), ".qagent-agent");
@@ -74,7 +74,10 @@ async function ensureChromium() {
74
74
  return true;
75
75
  console.log("Chromium not found — installing Playwright's Chromium (one-time download)...");
76
76
  const code = await new Promise((resolve) => {
77
- const child = (0, child_process_1.spawn)((0, paths_1.nodeBin)(), [(0, paths_1.playwrightCli)(), "install", "chromium"], { stdio: "inherit" });
77
+ const child = (0, child_process_1.spawn)((0, paths_1.nodeBin)(), [(0, paths_1.playwrightCli)(), "install", "chromium"], {
78
+ stdio: "inherit",
79
+ env: { ...process.env, ...(0, paths_1.childNodeEnv)() },
80
+ });
78
81
  child.on("close", resolve);
79
82
  child.on("error", () => resolve(null));
80
83
  });
package/dist/src/paths.js CHANGED
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.firstExisting = firstExisting;
37
+ exports.isElectron = isElectron;
38
+ exports.childNodeEnv = childNodeEnv;
37
39
  exports.packagedRoot = packagedRoot;
38
40
  exports.nodeBin = nodeBin;
39
41
  exports.agentNodeModules = agentNodeModules;
@@ -61,6 +63,19 @@ function firstExisting(candidates) {
61
63
  }
62
64
  return null;
63
65
  }
66
+ /** True when running inside Electron (the desktop app) rather than plain Node. */
67
+ function isElectron() {
68
+ return Boolean(process.versions?.electron);
69
+ }
70
+ /**
71
+ * Environment additions for spawned child node processes. Under Electron,
72
+ * `process.execPath` is the Electron binary, so `nodeBin()` returns it — set
73
+ * ELECTRON_RUN_AS_NODE=1 so it runs the given script as Node rather than
74
+ * launching another app window.
75
+ */
76
+ function childNodeEnv() {
77
+ return isElectron() ? { ELECTRON_RUN_AS_NODE: "1" } : {};
78
+ }
64
79
  /** True when running as a Node Single Executable Application (the packaged .exe). */
65
80
  function isSea() {
66
81
  try {
@@ -1,10 +1,10 @@
1
1
  "use strict";
2
2
  /**
3
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.
4
+ * (`_PLAYWRIGHT_CONFIG_TEMPLATE` + `_write_config`) and the injected-fixtures
5
+ * shim (`_fixtures_ts` + `_apply_fixtures`), reproduced faithfully so a spec
6
+ * that runs on the server and one that runs via the Local Agent behave
7
+ * identically including always-on DOM capture (raw + distilled).
8
8
  */
9
9
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
10
10
  if (k2 === undefined) k2 = k;
@@ -41,8 +41,8 @@ var __importStar = (this && this.__importStar) || (function () {
41
41
  })();
42
42
  Object.defineProperty(exports, "__esModule", { value: true });
43
43
  exports.writeConfig = writeConfig;
44
- exports.authFixturesTs = authFixturesTs;
45
- exports.applyAuthFixtures = applyAuthFixtures;
44
+ exports.fixturesTs = fixturesTs;
45
+ exports.applyFixtures = applyFixtures;
46
46
  const fs = __importStar(require("fs"));
47
47
  const path = __importStar(require("path"));
48
48
  const PLAYWRIGHT_CONFIG_TEMPLATE = `import { defineConfig } from '@playwright/test';
@@ -83,14 +83,37 @@ function writeConfig(specDir, workers, headless, baseUrl = "", storageState = ""
83
83
  fs.writeFileSync(path.join(specDir, "playwright.config.ts"), content, "utf-8");
84
84
  }
85
85
  /**
86
- * TypeScript for a generated `fixtures.ts` that replays sessionStorage
87
- * port of `_auth_fixtures_ts`.
86
+ * TypeScript for a generated `fixtures.ts` that captures the page DOM after
87
+ * every test, and optionally replays a captured sessionStorage — port of
88
+ * `_fixtures_ts`.
89
+ *
90
+ * The module re-exports Playwright's `test` extended with:
91
+ * - an `{auto: true}` fixture that, after each test, best-effort attaches the
92
+ * live page's raw HTML (`qagent-dom-raw`) and a distilled inventory of the
93
+ * page's interactable elements (`qagent-dom-distilled`) via `testInfo.attach`,
94
+ * so self-heal can ground on the real DOM. Wrapped in try/catch so it never
95
+ * fails a test.
96
+ * - (only when `replaySession`) a `context` override that restores the captured
97
+ * `sessionStorage` (MSAL/SPA tokens) for the current origin before app code runs.
88
98
  *
89
99
  * @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.
100
+ * embedded as a JSON-encoded string literal, read at runtime only when
101
+ * `replaySession` is set.
102
+ * @param replaySession Whether to inject the sessionStorage-replay `context`
103
+ * override (DOM capture is always injected regardless).
92
104
  */
93
- function authFixturesTs(sessionFile) {
105
+ function fixturesTs(sessionFile, replaySession) {
106
+ const contextFixture = replaySession
107
+ ? " context: async ({ context }, use) => {\n" +
108
+ " await context.addInitScript((sessions: Record<string, Record<string, string>>) => {\n" +
109
+ " try {\n" +
110
+ " const entries = sessions[location.origin];\n" +
111
+ " if (entries) for (const k in entries) window.sessionStorage.setItem(k, entries[k]);\n" +
112
+ " } catch {}\n" +
113
+ " }, SESSIONS);\n" +
114
+ " await use(context);\n" +
115
+ " },\n"
116
+ : "";
94
117
  return ("import { test as base, expect } from '@playwright/test';\n" +
95
118
  "import * as fs from 'fs';\n" +
96
119
  "\n" +
@@ -98,47 +121,71 @@ function authFixturesTs(sessionFile) {
98
121
  "let SESSIONS: Record<string, Record<string, string>> = {};\n" +
99
122
  "try { SESSIONS = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf-8')); } catch {}\n" +
100
123
  "\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" +
124
+ "export const test = base.extend<{ _domCapture: void }>({\n" +
125
+ contextFixture +
126
+ " // Always-on DOM capture: after each test, snapshot the live page so the\n" +
127
+ " // runner (evidence) and self-heal loop (real selectors) can use it.\n" +
128
+ " _domCapture: [async ({ page }, use, testInfo) => {\n" +
129
+ " await use();\n" +
130
+ " try {\n" +
131
+ " const raw = await page.content();\n" +
132
+ " const rawPath = testInfo.outputPath('qagent-dom-raw.html');\n" +
133
+ " fs.writeFileSync(rawPath, raw, 'utf-8');\n" +
134
+ " await testInfo.attach('qagent-dom-raw', { path: rawPath, contentType: 'text/html' });\n" +
135
+ " } catch {}\n" +
136
+ " try {\n" +
137
+ " const snapshot = await page.evaluate(() => {\n" +
138
+ " const SEL = 'a,button,input,select,textarea,[role],[data-testid],[data-test],[id]';\n" +
139
+ " const elements = Array.from(document.querySelectorAll(SEL)).slice(0, 400).map((node) => {\n" +
140
+ " const el = node as HTMLElement;\n" +
141
+ " const text = (el.innerText || '').trim().slice(0, 80);\n" +
142
+ " return {\n" +
143
+ " tag: el.tagName.toLowerCase(),\n" +
144
+ " role: el.getAttribute('role') || undefined,\n" +
145
+ " testId: el.getAttribute('data-testid') || el.getAttribute('data-test') || undefined,\n" +
146
+ " id: el.id || undefined,\n" +
147
+ " name: el.getAttribute('name') || undefined,\n" +
148
+ " text: text || undefined,\n" +
149
+ " placeholder: el.getAttribute('placeholder') || undefined,\n" +
150
+ " type: el.getAttribute('type') || undefined,\n" +
151
+ " };\n" +
152
+ " });\n" +
153
+ " return { path: location.pathname, url: location.href, elements };\n" +
154
+ " });\n" +
155
+ " const distilledPath = testInfo.outputPath('qagent-dom-distilled.json');\n" +
156
+ " fs.writeFileSync(distilledPath, JSON.stringify(snapshot), 'utf-8');\n" +
157
+ " await testInfo.attach('qagent-dom-distilled', { path: distilledPath, contentType: 'application/json' });\n" +
158
+ " } catch {}\n" +
159
+ " }, { auto: true }],\n" +
111
160
  "});\n" +
112
161
  "\n" +
113
162
  "export { expect };\n" +
114
163
  "export type * from '@playwright/test';\n");
115
164
  }
116
165
  /**
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.
166
+ * Point each spec's Playwright import at the generated `fixtures.ts` and write
167
+ * it — port of `_apply_fixtures`. Only the module specifier is touched.
168
+ *
169
+ * DOM capture is always on, so fixtures are ALWAYS injected (unlike the previous
170
+ * auth-only behavior): each spec's `'@playwright/test'` import is rewritten to
171
+ * `'./fixtures'` and `fixtures.ts` is (re)written every run. `replaySession` only
172
+ * controls whether the generated module also replays the captured sessionStorage.
120
173
  *
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).
174
+ * The Python version globs `*.spec.ts` in `specDir`; the agent already knows
175
+ * exactly which spec filenames it wrote for this job, so `specFilenames` is
176
+ * passed explicitly instead of re-scanning the directory.
125
177
  *
126
178
  * @param specDir The job's local workdir containing the spec files.
127
179
  * @param specFilenames The spec filenames written into `specDir` for this job.
128
180
  * @param sessionFile Absolute path to the `sessionStorage.json` snapshot embedded
129
181
  * in the generated `fixtures.ts`.
130
- * @param enabled Whether sessionStorage replay is active for this run.
182
+ * @param replaySession Whether sessionStorage replay is active for this run.
131
183
  */
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
- ];
184
+ function applyFixtures(specDir, specFilenames, sessionFile, replaySession) {
185
+ const replacements = [
186
+ ["'@playwright/test'", "'./fixtures'"],
187
+ ['"@playwright/test"', '"./fixtures"'],
188
+ ];
142
189
  for (const filename of specFilenames) {
143
190
  const specPath = path.join(specDir, filename);
144
191
  let text;
@@ -156,7 +203,5 @@ function applyAuthFixtures(specDir, specFilenames, sessionFile, enabled) {
156
203
  fs.writeFileSync(specPath, newText, "utf-8");
157
204
  }
158
205
  }
159
- if (enabled) {
160
- fs.writeFileSync(path.join(specDir, "fixtures.ts"), authFixturesTs(sessionFile), "utf-8");
161
- }
206
+ fs.writeFileSync(path.join(specDir, "fixtures.ts"), fixturesTs(sessionFile, replaySession), "utf-8");
162
207
  }
@@ -22,6 +22,10 @@ const ATTACHMENT_KIND_MAP = {
22
22
  screenshot: "screenshot",
23
23
  video: "video",
24
24
  trace: "trace",
25
+ // DOM captured by the injected fixtures (see playwrightConfig.fixturesTs):
26
+ // raw page HTML + a distilled interactable-element inventory.
27
+ "qagent-dom-raw": "dom",
28
+ "qagent-dom-distilled": "dom-distilled",
25
29
  };
26
30
  /* eslint-enable @typescript-eslint/no-explicit-any */
27
31
  /**