@q-agent/agent 0.1.11 → 0.1.13

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.
@@ -49,14 +49,14 @@ const PLAYWRIGHT_CONFIG_TEMPLATE = `import { defineConfig } from '@playwright/te
49
49
 
50
50
  export default defineConfig({
51
51
  testDir: '.',
52
- timeout: 30000,
52
+ timeout: __TIMEOUT__,
53
53
  workers: __WORKERS__,
54
54
  reporter: [['json', { outputFile: 'report.json' }]],
55
55
  use: {
56
56
  headless: __HEADLESS__,
57
57
  screenshot: 'only-on-failure',
58
- video: 'retain-on-failure',
59
- trace: 'retain-on-failure',
58
+ video: '__VIDEO__',
59
+ trace: '__TRACE__',
60
60
  __EXTRA_USE__ },
61
61
  });
62
62
  `;
@@ -69,16 +69,26 @@ __EXTRA_USE__ },
69
69
  * @param headless Whether the browser runs headless.
70
70
  * @param baseUrl When non-empty, injected as `use.baseURL`.
71
71
  * @param storageState When non-empty, an absolute path injected as `use.storageState`.
72
+ * @param opts Heal-tuning knobs (timeouts + evidence); defaults match a normal run.
72
73
  */
73
- function writeConfig(specDir, workers, headless, baseUrl = "", storageState = "") {
74
+ function writeConfig(specDir, workers, headless, baseUrl = "", storageState = "", opts = {}) {
75
+ const { testTimeoutMs = 30000, actionTimeoutMs, heavyEvidence = true } = opts;
74
76
  const extraLines = [];
75
77
  if (baseUrl)
76
78
  extraLines.push(` baseURL: ${JSON.stringify(baseUrl)},`);
77
79
  if (storageState)
78
80
  extraLines.push(` storageState: ${JSON.stringify(storageState)},`);
81
+ if (actionTimeoutMs != null) {
82
+ extraLines.push(` actionTimeout: ${Math.trunc(actionTimeoutMs)},`);
83
+ extraLines.push(` navigationTimeout: ${Math.trunc(actionTimeoutMs)},`);
84
+ }
79
85
  const extraUse = extraLines.length ? extraLines.join("\n") + "\n" : "";
80
- const content = PLAYWRIGHT_CONFIG_TEMPLATE.replace("__WORKERS__", String(workers))
86
+ const retain = heavyEvidence ? "retain-on-failure" : "off";
87
+ const content = PLAYWRIGHT_CONFIG_TEMPLATE.replace("__TIMEOUT__", String(Math.trunc(testTimeoutMs)))
88
+ .replace("__WORKERS__", String(workers))
81
89
  .replace("__HEADLESS__", headless ? "true" : "false")
90
+ .replace("__VIDEO__", retain)
91
+ .replace("__TRACE__", retain)
82
92
  .replace("__EXTRA_USE__", extraUse);
83
93
  fs.writeFileSync(path.join(specDir, "playwright.config.ts"), content, "utf-8");
84
94
  }
@@ -102,7 +112,7 @@ function writeConfig(specDir, workers, headless, baseUrl = "", storageState = ""
102
112
  * @param replaySession Whether to inject the sessionStorage-replay `context`
103
113
  * override (DOM capture is always injected regardless).
104
114
  */
105
- function fixturesTs(sessionFile, replaySession) {
115
+ function fixturesTs(sessionFile, replaySession, captureRaw = true) {
106
116
  const contextFixture = replaySession
107
117
  ? " context: async ({ context }, use) => {\n" +
108
118
  " await context.addInitScript((sessions: Record<string, Record<string, string>>) => {\n" +
@@ -114,6 +124,14 @@ function fixturesTs(sessionFile, replaySession) {
114
124
  " await use(context);\n" +
115
125
  " },\n"
116
126
  : "";
127
+ const rawBlock = captureRaw
128
+ ? " try {\n" +
129
+ " const raw = await page.content();\n" +
130
+ " const rawPath = testInfo.outputPath('qagent-dom-raw.html');\n" +
131
+ " fs.writeFileSync(rawPath, raw, 'utf-8');\n" +
132
+ " await testInfo.attach('qagent-dom-raw', { path: rawPath, contentType: 'text/html' });\n" +
133
+ " } catch {}\n"
134
+ : "";
117
135
  return ("import { test as base, expect } from '@playwright/test';\n" +
118
136
  "import * as fs from 'fs';\n" +
119
137
  "\n" +
@@ -127,35 +145,42 @@ function fixturesTs(sessionFile, replaySession) {
127
145
  " // runner (evidence) and self-heal loop (real selectors) can use it.\n" +
128
146
  " _domCapture: [async ({ page }, use, testInfo) => {\n" +
129
147
  " 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" +
148
+ " // Distilled inventory FIRST (what self-heal needs) — retry once after a\n" +
149
+ " // short settle so a transiently-busy failure page still yields real\n" +
150
+ " // elements instead of leaving the fixer with no DOM (#398).\n" +
151
+ " const runDistill = () => page.evaluate(() => {\n" +
152
+ " const SEL = 'a,button,input,select,textarea,[role],[data-testid],[data-test],[id]';\n" +
153
+ " const elements = Array.from(document.querySelectorAll(SEL)).slice(0, 400).map((node) => {\n" +
154
+ " const el = node as HTMLElement;\n" +
155
+ " const text = (el.innerText || '').trim().slice(0, 80);\n" +
156
+ " return {\n" +
157
+ " tag: el.tagName.toLowerCase(),\n" +
158
+ " role: el.getAttribute('role') || undefined,\n" +
159
+ " testId: el.getAttribute('data-testid') || el.getAttribute('data-test') || undefined,\n" +
160
+ " id: el.id || undefined,\n" +
161
+ " name: el.getAttribute('name') || undefined,\n" +
162
+ " text: text || undefined,\n" +
163
+ " placeholder: el.getAttribute('placeholder') || undefined,\n" +
164
+ " type: el.getAttribute('type') || undefined,\n" +
165
+ " };\n" +
154
166
  " });\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" +
167
+ " return { path: location.pathname, url: location.href, elements };\n" +
168
+ " });\n" +
169
+ " let snapshot: { path: string; url: string; elements: unknown[] } | null = null;\n" +
170
+ " for (let i = 0; i < 2; i++) {\n" +
171
+ " if (page.isClosed()) break;\n" +
172
+ " try { snapshot = await runDistill(); } catch { snapshot = null; }\n" +
173
+ " if (snapshot && Array.isArray(snapshot.elements) && snapshot.elements.length > 0) break;\n" +
174
+ " try { await page.waitForTimeout(400); } catch { break; }\n" +
175
+ " }\n" +
176
+ " if (snapshot) {\n" +
177
+ " try {\n" +
178
+ " const distilledPath = testInfo.outputPath('qagent-dom-distilled.json');\n" +
179
+ " fs.writeFileSync(distilledPath, JSON.stringify(snapshot), 'utf-8');\n" +
180
+ " await testInfo.attach('qagent-dom-distilled', { path: distilledPath, contentType: 'application/json' });\n" +
181
+ " } catch {}\n" +
182
+ " }\n" +
183
+ rawBlock +
159
184
  " }, { auto: true }],\n" +
160
185
  "});\n" +
161
186
  "\n" +
@@ -180,8 +205,10 @@ function fixturesTs(sessionFile, replaySession) {
180
205
  * @param sessionFile Absolute path to the `sessionStorage.json` snapshot embedded
181
206
  * in the generated `fixtures.ts`.
182
207
  * @param replaySession Whether sessionStorage replay is active for this run.
208
+ * @param captureRaw Whether to also capture the raw-HTML DOM attachment (off for
209
+ * intermediate heal attempts — #398).
183
210
  */
184
- function applyFixtures(specDir, specFilenames, sessionFile, replaySession) {
211
+ function applyFixtures(specDir, specFilenames, sessionFile, replaySession, captureRaw = true) {
185
212
  const replacements = [
186
213
  ["'@playwright/test'", "'./fixtures'"],
187
214
  ['"@playwright/test"', '"./fixtures"'],
@@ -203,5 +230,5 @@ function applyFixtures(specDir, specFilenames, sessionFile, replaySession) {
203
230
  fs.writeFileSync(specPath, newText, "utf-8");
204
231
  }
205
232
  }
206
- fs.writeFileSync(path.join(specDir, "fixtures.ts"), fixturesTs(sessionFile, replaySession), "utf-8");
233
+ fs.writeFileSync(path.join(specDir, "fixtures.ts"), fixturesTs(sessionFile, replaySession, captureRaw), "utf-8");
207
234
  }
@@ -476,9 +476,16 @@ async function processHealJob(cfg, job, heal) {
476
476
  const { storageState, sessionStoragePath } = auth;
477
477
  for (let attempt = 1; attempt <= heal.maxAttempts; attempt++) {
478
478
  fs.writeFileSync(path.join(workDir, spec.filename), currentCode, "utf-8");
479
- (0, playwrightConfig_1.writeConfig)(workDir, 1, job.headless, job.baseUrl, storageState);
479
+ // Heal re-runs fail fast (shorter timeouts) and skip heavy trace/video +
480
+ // raw-DOM capture except on the final attempt (#398).
481
+ const isFinalAttempt = attempt === heal.maxAttempts;
482
+ (0, playwrightConfig_1.writeConfig)(workDir, 1, job.headless, job.baseUrl, storageState, {
483
+ testTimeoutMs: heal.testTimeoutMs,
484
+ actionTimeoutMs: heal.actionTimeoutMs,
485
+ heavyEvidence: isFinalAttempt,
486
+ });
480
487
  const replaySession = Boolean(job.manualAuth && storageState && sessionStoragePath && fs.statSync(sessionStoragePath).size > 0);
481
- (0, playwrightConfig_1.applyFixtures)(workDir, [spec.filename], sessionStoragePath || path.join(workDir, "sessionStorage.json"), replaySession);
488
+ (0, playwrightConfig_1.applyFixtures)(workDir, [spec.filename], sessionStoragePath || path.join(workDir, "sessionStorage.json"), replaySession, isFinalAttempt);
482
489
  await progress("running", attempt, `Running spec (attempt ${attempt}/${heal.maxAttempts})`);
483
490
  const started = Date.now();
484
491
  const { stdout, stderr, timedOut } = await runPlaywright(workDir, 1, spec.filename);
@@ -849,11 +856,18 @@ async function processExplorationJob(cfg, session) {
849
856
  }
850
857
  }
851
858
  const storageState = origin && (0, session_1.hasValidSession)(origin) ? (0, session_1.sessionPathsForOrigin)(origin).storageStatePath : "";
859
+ // Pair the sessionStorage snapshot (MSAL/SPA auth tokens) with the saved
860
+ // session so the explore browser authenticates the same way a run does — the
861
+ // run path replays it via fixtures (see `replaySession`). Without it an
862
+ // MSAL/SPA app boots unauthenticated and bounces to login.
863
+ const sessionStoragePath = storageState && origin && (0, session_1.hasSessionStorage)(origin) ? (0, session_1.sessionPathsForOrigin)(origin).sessionStoragePath : "";
852
864
  const script = (0, paths_1.vendorExploreScript)();
853
865
  const nm = (0, paths_1.agentNodeModules)();
854
866
  const args = [script, session.baseUrl];
855
867
  if (storageState)
856
868
  args.push(storageState);
869
+ if (storageState && sessionStoragePath)
870
+ args.push(sessionStoragePath);
857
871
  // Run the explore browser HEADED on the paired device: the user watches the
858
872
  // session, and a headed browser dodges WAF/bot-protection that blocks headless.
859
873
  const child = (0, child_process_1.spawn)((0, paths_1.nodeBin)(), args, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@q-agent/agent",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
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": {
@@ -13,8 +13,11 @@
13
13
  // whole session, reading newline-delimited JSON commands on stdin and writing
14
14
  // newline-delimited JSON responses on stdout.
15
15
  //
16
- // Args: baseURL, [storageState] (storageState = absolute path to a saved
17
- // Playwright session for auth reuse, per ADR 0002).
16
+ // Args: baseURL, [storageState], [sessionState] (storageState = absolute path
17
+ // to a saved Playwright session for auth reuse, per ADR 0002; sessionState =
18
+ // absolute path to the sibling sessionStorage.json snapshot, replayed for auth
19
+ // reuse on MSAL/SPA apps whose token lives in sessionStorage — which Playwright's
20
+ // storageState cannot persist).
18
21
  //
19
22
  // Protocol (one JSON object per line, each way):
20
23
  // {"cmd":"observe"}
@@ -29,8 +32,9 @@
29
32
  // self-heal loop's tolerance for stale actions).
30
33
  const { chromium } = require('playwright');
31
34
  const readline = require('readline');
35
+ const fs = require('fs');
32
36
 
33
- const [, , baseURL, storageState] = process.argv;
37
+ const [, , baseURL, storageState, sessionState] = process.argv;
34
38
 
35
39
  process.on('unhandledRejection', (e) => console.error('explore unhandledRejection:', e && (e.message || e)));
36
40
  process.on('uncaughtException', (e) => console.error('explore uncaughtException:', e && (e.message || e)));
@@ -166,6 +170,21 @@ async function handle(line) {
166
170
  const contextOpts = { baseURL };
167
171
  if (storageState) contextOpts.storageState = storageState;
168
172
  const context = await browser.newContext(contextOpts);
173
+ // Replay the captured sessionStorage (MSAL/SPA auth tokens) for the matching
174
+ // origin BEFORE any app code runs — mirrors playwright_runner._fixtures_ts.
175
+ // storageState persists cookies + localStorage but NOT sessionStorage, so
176
+ // without this an MSAL/SPA app boots unauthenticated and bounces to login even
177
+ // with a valid saved session. The snapshot is {origin: {key: value}}.
178
+ if (sessionState) {
179
+ let sessions = {};
180
+ try { sessions = JSON.parse(fs.readFileSync(sessionState, 'utf-8')); } catch {}
181
+ await context.addInitScript((byOrigin) => {
182
+ try {
183
+ const entries = byOrigin[location.origin];
184
+ if (entries) for (const k in entries) window.sessionStorage.setItem(k, entries[k]);
185
+ } catch {}
186
+ }, sessions);
187
+ }
169
188
  page = await context.newPage();
170
189
 
171
190
  // Land on the app before the first observe so step 1 sees the real page,