@q-agent/agent 0.1.1 → 0.1.6
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 +28 -1
- package/dist/src/api.js +54 -0
- package/dist/src/cli.js +11 -2
- package/dist/src/config.js +25 -0
- package/dist/src/ensureBrowser.js +4 -1
- package/dist/src/paths.js +15 -0
- package/dist/src/playwrightConfig.js +87 -42
- package/dist/src/report.js +4 -0
- package/dist/src/runner.js +329 -62
- package/dist/src/ui.js +400 -161
- package/dist/test/api.test.js +19 -0
- package/dist/test/playwrightConfig.test.js +84 -0
- package/dist/test/report.test.js +4 -0
- package/package.json +36 -2
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
|
-
##
|
|
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
|
@@ -41,10 +41,15 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
41
41
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
42
|
exports.ApiError = void 0;
|
|
43
43
|
exports.redeemDevice = redeemDevice;
|
|
44
|
+
exports.disconnectDevice = disconnectDevice;
|
|
44
45
|
exports.claimNextJob = claimNextJob;
|
|
46
|
+
exports.claimNextCapture = claimNextCapture;
|
|
47
|
+
exports.postCaptureComplete = postCaptureComplete;
|
|
45
48
|
exports.postEvent = postEvent;
|
|
46
49
|
exports.postResult = postResult;
|
|
47
50
|
exports.postEvidence = postEvidence;
|
|
51
|
+
exports.postHealFix = postHealFix;
|
|
52
|
+
exports.postHealFinalize = postHealFinalize;
|
|
48
53
|
exports.postComplete = postComplete;
|
|
49
54
|
const fs = __importStar(require("fs"));
|
|
50
55
|
/** Raised for any non-2xx/204 response; carries the HTTP status for callers that care (e.g. 409). */
|
|
@@ -76,6 +81,16 @@ async function redeemDevice(serverUrl, code, name) {
|
|
|
76
81
|
await throwIfNotOk(res);
|
|
77
82
|
return (await res.json());
|
|
78
83
|
}
|
|
84
|
+
/** Self-revoke this device on the server (the "Disconnect" action). Best-effort:
|
|
85
|
+
* the caller wipes the local token regardless, so a failure here only means the
|
|
86
|
+
* server list clears on `last_seen_at` staleness instead of immediately. */
|
|
87
|
+
async function disconnectDevice(cfg) {
|
|
88
|
+
const res = await fetch(`${cfg.serverUrl}/agent/disconnect`, {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: authHeaders(cfg.deviceToken),
|
|
91
|
+
});
|
|
92
|
+
await throwIfNotOk(res);
|
|
93
|
+
}
|
|
79
94
|
/** Long-poll claim the next queued job for this device's owner. `null` on 204 (nothing queued). */
|
|
80
95
|
async function claimNextJob(cfg) {
|
|
81
96
|
const res = await fetch(`${cfg.serverUrl}/agent/jobs/next`, {
|
|
@@ -87,6 +102,26 @@ async function claimNextJob(cfg) {
|
|
|
87
102
|
await throwIfNotOk(res);
|
|
88
103
|
return (await res.json());
|
|
89
104
|
}
|
|
105
|
+
/** Claim the next queued auth-capture for this device's owner. `null` on 204. */
|
|
106
|
+
async function claimNextCapture(cfg) {
|
|
107
|
+
const res = await fetch(`${cfg.serverUrl}/agent/auth/next`, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: authHeaders(cfg.deviceToken),
|
|
110
|
+
});
|
|
111
|
+
if (res.status === 204)
|
|
112
|
+
return null;
|
|
113
|
+
await throwIfNotOk(res);
|
|
114
|
+
return (await res.json());
|
|
115
|
+
}
|
|
116
|
+
/** Report a capture's outcome so the server clears "capturing" + stamps the marker. */
|
|
117
|
+
async function postCaptureComplete(cfg, captureId, ok, error) {
|
|
118
|
+
const res = await fetch(`${cfg.serverUrl}/agent/auth/${captureId}/complete`, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: { ...authHeaders(cfg.deviceToken), "Content-Type": "application/json" },
|
|
121
|
+
body: JSON.stringify({ ok, error }),
|
|
122
|
+
});
|
|
123
|
+
await throwIfNotOk(res);
|
|
124
|
+
}
|
|
90
125
|
/** Push one progress event; the server re-emits it on the run's WebSocket unchanged. */
|
|
91
126
|
async function postEvent(cfg, executionId, event, payload) {
|
|
92
127
|
const res = await fetch(`${cfg.serverUrl}/agent/jobs/${executionId}/events`, {
|
|
@@ -120,6 +155,25 @@ async function postEvidence(cfg, executionId, ev) {
|
|
|
120
155
|
});
|
|
121
156
|
await throwIfNotOk(res);
|
|
122
157
|
}
|
|
158
|
+
/** Ask the server to classify + fix one failed heal attempt (Claude + KB live server-side). */
|
|
159
|
+
async function postHealFix(cfg, caseId, body) {
|
|
160
|
+
const res = await fetch(`${cfg.serverUrl}/agent/heal/${caseId}/fix`, {
|
|
161
|
+
method: "POST",
|
|
162
|
+
headers: { ...authHeaders(cfg.deviceToken), "Content-Type": "application/json" },
|
|
163
|
+
body: JSON.stringify(body),
|
|
164
|
+
});
|
|
165
|
+
await throwIfNotOk(res);
|
|
166
|
+
return (await res.json());
|
|
167
|
+
}
|
|
168
|
+
/** Persist the heal's final outcome + feed a passing DOM-grounded heal into the KB. */
|
|
169
|
+
async function postHealFinalize(cfg, caseId, body) {
|
|
170
|
+
const res = await fetch(`${cfg.serverUrl}/agent/heal/${caseId}/finalize`, {
|
|
171
|
+
method: "POST",
|
|
172
|
+
headers: { ...authHeaders(cfg.deviceToken), "Content-Type": "application/json" },
|
|
173
|
+
body: JSON.stringify(body),
|
|
174
|
+
});
|
|
175
|
+
await throwIfNotOk(res);
|
|
176
|
+
}
|
|
123
177
|
/** Finalize an execution with its aggregate counts + captured log tail. */
|
|
124
178
|
async function postComplete(cfg, executionId, body) {
|
|
125
179
|
const res = await fetch(`${cfg.serverUrl}/agent/jobs/${executionId}/complete`, {
|
package/dist/src/cli.js
CHANGED
|
@@ -48,6 +48,7 @@ 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");
|
|
51
52
|
/** How the user invoked this agent, for accurate "run X" hints: the bare command
|
|
52
53
|
* inside a packaged bundle, else the `npx` form of the published package. */
|
|
53
54
|
function invocation() {
|
|
@@ -59,10 +60,10 @@ program
|
|
|
59
60
|
.command("pair")
|
|
60
61
|
.description("Redeem a one-time pairing code (from the Q-Agent SPA) for a device token")
|
|
61
62
|
.argument("<code>", "pairing code shown in the SPA")
|
|
62
|
-
.option("--server <url>", "Q-Agent server base URL
|
|
63
|
+
.option("--server <url>", "Q-Agent server base URL (defaults to the server baked into this build)")
|
|
63
64
|
.option("--name <name>", "device name shown in the paired-devices list", os.hostname())
|
|
64
65
|
.action(async (code, opts) => {
|
|
65
|
-
const serverUrl = opts.server.replace(/\/+$/, "");
|
|
66
|
+
const serverUrl = (opts.server || (0, config_1.defaultServerUrl)() || "http://127.0.0.1:8787").replace(/\/+$/, "");
|
|
66
67
|
try {
|
|
67
68
|
const { deviceToken, deviceId } = await (0, api_1.redeemDevice)(serverUrl, code, opts.name);
|
|
68
69
|
const cfg = { serverUrl, deviceToken, deviceId, deviceName: opts.name };
|
|
@@ -117,6 +118,14 @@ program
|
|
|
117
118
|
(0, config_1.clearConfig)();
|
|
118
119
|
console.log(`Device token cleared. Run \`${invocation()} pair <code>\` to pair again.`);
|
|
119
120
|
});
|
|
121
|
+
program
|
|
122
|
+
.command("ui", { isDefault: true })
|
|
123
|
+
.description("Open the local web UI to pair and watch progress in your browser (default)")
|
|
124
|
+
.option("--port <port>", "port for the local UI server (default 7420)")
|
|
125
|
+
.option("--no-open", "start the UI server without opening a browser")
|
|
126
|
+
.action((opts) => {
|
|
127
|
+
(0, ui_1.startUi)({ port: opts.port ? Number(opts.port) : undefined, open: opts.open !== false });
|
|
128
|
+
});
|
|
120
129
|
program.parseAsync(process.argv).catch((err) => {
|
|
121
130
|
console.error(err);
|
|
122
131
|
process.exitCode = 1;
|
package/dist/src/config.js
CHANGED
|
@@ -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"], {
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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.
|
|
45
|
-
exports.
|
|
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
|
|
87
|
-
* port of
|
|
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
|
|
91
|
-
*
|
|
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
|
|
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
|
-
|
|
103
|
-
"
|
|
104
|
-
"
|
|
105
|
-
"
|
|
106
|
-
"
|
|
107
|
-
"
|
|
108
|
-
"
|
|
109
|
-
"
|
|
110
|
-
"
|
|
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
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
-
*
|
|
123
|
-
*
|
|
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
|
|
182
|
+
* @param replaySession Whether sessionStorage replay is active for this run.
|
|
131
183
|
*/
|
|
132
|
-
function
|
|
133
|
-
const replacements =
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/src/report.js
CHANGED
|
@@ -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
|
/**
|