@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.
- package/README.md +86 -0
- package/dist/src/api.js +131 -0
- package/dist/src/cli.js +118 -0
- package/dist/src/config.js +92 -0
- package/dist/src/ensureBrowser.js +102 -0
- package/dist/src/playwrightConfig.js +162 -0
- package/dist/src/report.js +106 -0
- package/dist/src/runner.js +396 -0
- package/dist/src/session.js +84 -0
- package/dist/test/api.test.js +146 -0
- package/dist/test/report.test.js +192 -0
- package/package.json +39 -0
- package/vendor/capture_auth.cjs +136 -0
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# @q-agent/agent — Q-Agent Local Agent
|
|
2
|
+
|
|
3
|
+
Runs Playwright test executions on **your own machine** instead of the
|
|
4
|
+
Q-Agent server. This means:
|
|
5
|
+
|
|
6
|
+
- Manual login / MFA happens in a real, headed browser right where you are
|
|
7
|
+
sitting — not on a headless server.
|
|
8
|
+
- Your session cookies/localStorage/sessionStorage **never leave this
|
|
9
|
+
machine**. Only test specs, pass/fail results, and evidence (screenshots/
|
|
10
|
+
video/trace) are sent back to the server.
|
|
11
|
+
|
|
12
|
+
## Prerequisites
|
|
13
|
+
|
|
14
|
+
- Node.js 18+
|
|
15
|
+
|
|
16
|
+
Chromium is installed automatically: the first time you run `qagent-agent start`
|
|
17
|
+
the agent downloads Playwright's Chromium if it isn't already present (one-time,
|
|
18
|
+
~100 MB, with visible progress). No manual `npx playwright install` step is needed.
|
|
19
|
+
|
|
20
|
+
## Install & run
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npx @q-agent/agent pair <code> --server https://your-qagent-server.example.com/api
|
|
24
|
+
npx @q-agent/agent start
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`--server` is the origin serving the API's `/agent/...` routes. On the
|
|
28
|
+
same-origin (Cloudflare tunnel) deployment that's `<origin>/api`. In local dev
|
|
29
|
+
with the API on its own port, use the API directly (e.g. `http://127.0.0.1:8787`).
|
|
30
|
+
|
|
31
|
+
Or, if working from a checkout of this package:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
cd agent
|
|
35
|
+
npm install
|
|
36
|
+
npm run build
|
|
37
|
+
node dist/src/cli.js pair <code> --server http://127.0.0.1:8787
|
|
38
|
+
node dist/src/cli.js start
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Commands
|
|
42
|
+
|
|
43
|
+
| Command | Description |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| `pair <code> [--server <url>] [--name <name>]` | Redeem a one-time pairing code (generated in the Q-Agent SPA's Local Agent screen) for a durable device token. Stored at `~/.qagent-agent/config.json` (mode 0600). |
|
|
46
|
+
| `start [--server <url>]` | Long-poll the server for queued `local-agent` executions and run them. Ctrl-C shuts down cleanly, killing any in-flight Playwright/capture process. |
|
|
47
|
+
| `status` | Show whether this machine is paired, and to which server. |
|
|
48
|
+
| `logout` | Forget the device token. |
|
|
49
|
+
|
|
50
|
+
## How a job runs
|
|
51
|
+
|
|
52
|
+
1. Claim a job (`POST /agent/jobs/next`) — spec sources + run params, **never** any session/credentials.
|
|
53
|
+
2. Write the specs and a `playwright.config.ts` into a temp workdir.
|
|
54
|
+
3. If the project requires manual login and no valid local session exists for
|
|
55
|
+
its origin yet, open a **headed** browser (`vendor/capture_auth.cjs`) for
|
|
56
|
+
you to log in. The captured session is saved under
|
|
57
|
+
`~/.qagent-agent/sessions/<origin>/` and reused on future runs until it
|
|
58
|
+
expires.
|
|
59
|
+
4. Run `@playwright/test` headed, parse `report.json`.
|
|
60
|
+
5. Push each case's result, its evidence (screenshots/video/trace), and
|
|
61
|
+
progress events back to the server, then mark the job complete.
|
|
62
|
+
|
|
63
|
+
## Known limitation (flag for the server cleanup phase)
|
|
64
|
+
|
|
65
|
+
`POST /agent/jobs/next`'s `specs[]` entries currently only carry
|
|
66
|
+
`{filename, code}` — not the case's full `ticketExternalId`/`caseCode`
|
|
67
|
+
(even though the server has both in scope when building the payload, see
|
|
68
|
+
`api/app/routers/agent.py` `claim_next_job`). The agent recovers a
|
|
69
|
+
best-effort identity by parsing the `{shortTicket}-{caseCode}.spec.ts`
|
|
70
|
+
filename convention (`src/report.ts` `parseSpecIdentity`), assuming the
|
|
71
|
+
fixed `TC-NN` case-code format used everywhere else in this codebase. This
|
|
72
|
+
only recovers the ticket's **short numeric suffix** (e.g. `"1428"`), not its
|
|
73
|
+
full provider-prefixed external id (e.g. `"SUR-1428"`) — so:
|
|
74
|
+
|
|
75
|
+
- `exec.case.running` / `exec.case.result` WS events carry that short id in
|
|
76
|
+
`ticket`, which may not match what the frontend expects to key rows by.
|
|
77
|
+
- `POST /agent/jobs/{id}/evidence` requires an **exact** `ticket_external_id`
|
|
78
|
+
match server-side (`ExecutionResult.ticket_external_id == ticket_external_id`)
|
|
79
|
+
— the short id will likely **not** match, so evidence uploads may 404
|
|
80
|
+
until this is fixed.
|
|
81
|
+
|
|
82
|
+
The client (`src/api.ts` `JobSpec`, `src/runner.ts` `identityFor`) already
|
|
83
|
+
prefers explicit `ticketExternalId`/`caseCode` fields on each spec entry if
|
|
84
|
+
present, so the fix is additive: add those two fields to the `specs.append(...)`
|
|
85
|
+
call in `claim_next_job` (the loop variable `r` already has them in scope) —
|
|
86
|
+
no agent-side change needed once that lands.
|
package/dist/src/api.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Typed HTTP client for the Local Agent wire protocol served by
|
|
4
|
+
* `api/app/routers/agent.py`. Every job call sends
|
|
5
|
+
* `Authorization: Bearer <deviceToken>`. Uses Node's built-in `fetch`
|
|
6
|
+
* (Node 18+) — no extra HTTP dependency.
|
|
7
|
+
*/
|
|
8
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
9
|
+
if (k2 === undefined) k2 = k;
|
|
10
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
11
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
12
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
13
|
+
}
|
|
14
|
+
Object.defineProperty(o, k2, desc);
|
|
15
|
+
}) : (function(o, m, k, k2) {
|
|
16
|
+
if (k2 === undefined) k2 = k;
|
|
17
|
+
o[k2] = m[k];
|
|
18
|
+
}));
|
|
19
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
20
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
21
|
+
}) : function(o, v) {
|
|
22
|
+
o["default"] = v;
|
|
23
|
+
});
|
|
24
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
25
|
+
var ownKeys = function(o) {
|
|
26
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
27
|
+
var ar = [];
|
|
28
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
29
|
+
return ar;
|
|
30
|
+
};
|
|
31
|
+
return ownKeys(o);
|
|
32
|
+
};
|
|
33
|
+
return function (mod) {
|
|
34
|
+
if (mod && mod.__esModule) return mod;
|
|
35
|
+
var result = {};
|
|
36
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
37
|
+
__setModuleDefault(result, mod);
|
|
38
|
+
return result;
|
|
39
|
+
};
|
|
40
|
+
})();
|
|
41
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
+
exports.ApiError = void 0;
|
|
43
|
+
exports.redeemDevice = redeemDevice;
|
|
44
|
+
exports.claimNextJob = claimNextJob;
|
|
45
|
+
exports.postEvent = postEvent;
|
|
46
|
+
exports.postResult = postResult;
|
|
47
|
+
exports.postEvidence = postEvidence;
|
|
48
|
+
exports.postComplete = postComplete;
|
|
49
|
+
const fs = __importStar(require("fs"));
|
|
50
|
+
/** Raised for any non-2xx/204 response; carries the HTTP status for callers that care (e.g. 409). */
|
|
51
|
+
class ApiError extends Error {
|
|
52
|
+
status;
|
|
53
|
+
body;
|
|
54
|
+
constructor(status, body) {
|
|
55
|
+
super(`Request failed with status ${status}: ${body.slice(0, 500)}`);
|
|
56
|
+
this.status = status;
|
|
57
|
+
this.body = body;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
exports.ApiError = ApiError;
|
|
61
|
+
function authHeaders(token) {
|
|
62
|
+
return { Authorization: `Bearer ${token}` };
|
|
63
|
+
}
|
|
64
|
+
async function throwIfNotOk(res) {
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
throw new ApiError(res.status, await res.text().catch(() => ""));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Redeem a one-time pairing code for a durable device token (the `pair` command). */
|
|
70
|
+
async function redeemDevice(serverUrl, code, name) {
|
|
71
|
+
const res = await fetch(`${serverUrl}/agent/devices/redeem`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "Content-Type": "application/json" },
|
|
74
|
+
body: JSON.stringify({ code, name }),
|
|
75
|
+
});
|
|
76
|
+
await throwIfNotOk(res);
|
|
77
|
+
return (await res.json());
|
|
78
|
+
}
|
|
79
|
+
/** Long-poll claim the next queued job for this device's owner. `null` on 204 (nothing queued). */
|
|
80
|
+
async function claimNextJob(cfg) {
|
|
81
|
+
const res = await fetch(`${cfg.serverUrl}/agent/jobs/next`, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: authHeaders(cfg.deviceToken),
|
|
84
|
+
});
|
|
85
|
+
if (res.status === 204)
|
|
86
|
+
return null;
|
|
87
|
+
await throwIfNotOk(res);
|
|
88
|
+
return (await res.json());
|
|
89
|
+
}
|
|
90
|
+
/** Push one progress event; the server re-emits it on the run's WebSocket unchanged. */
|
|
91
|
+
async function postEvent(cfg, executionId, event, payload) {
|
|
92
|
+
const res = await fetch(`${cfg.serverUrl}/agent/jobs/${executionId}/events`, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: { ...authHeaders(cfg.deviceToken), "Content-Type": "application/json" },
|
|
95
|
+
body: JSON.stringify({ event, payload }),
|
|
96
|
+
});
|
|
97
|
+
await throwIfNotOk(res);
|
|
98
|
+
}
|
|
99
|
+
/** Push one parsed case result; the server matches it to its `ExecutionResult` by filename. */
|
|
100
|
+
async function postResult(cfg, executionId, entry) {
|
|
101
|
+
const res = await fetch(`${cfg.serverUrl}/agent/jobs/${executionId}/results`, {
|
|
102
|
+
method: "POST",
|
|
103
|
+
headers: { ...authHeaders(cfg.deviceToken), "Content-Type": "application/json" },
|
|
104
|
+
body: JSON.stringify(entry),
|
|
105
|
+
});
|
|
106
|
+
await throwIfNotOk(res);
|
|
107
|
+
}
|
|
108
|
+
/** Multipart-upload one evidence artifact (screenshot/video/trace). */
|
|
109
|
+
async function postEvidence(cfg, executionId, ev) {
|
|
110
|
+
const data = fs.readFileSync(ev.filePath);
|
|
111
|
+
const form = new FormData();
|
|
112
|
+
form.set("ticket_external_id", ev.ticketExternalId);
|
|
113
|
+
form.set("case_code", ev.caseCode);
|
|
114
|
+
form.set("kind", ev.kind);
|
|
115
|
+
form.set("file", new Blob([data]), ev.filename);
|
|
116
|
+
const res = await fetch(`${cfg.serverUrl}/agent/jobs/${executionId}/evidence`, {
|
|
117
|
+
method: "POST",
|
|
118
|
+
headers: authHeaders(cfg.deviceToken),
|
|
119
|
+
body: form,
|
|
120
|
+
});
|
|
121
|
+
await throwIfNotOk(res);
|
|
122
|
+
}
|
|
123
|
+
/** Finalize an execution with its aggregate counts + captured log tail. */
|
|
124
|
+
async function postComplete(cfg, executionId, body) {
|
|
125
|
+
const res = await fetch(`${cfg.serverUrl}/agent/jobs/${executionId}/complete`, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: { ...authHeaders(cfg.deviceToken), "Content-Type": "application/json" },
|
|
128
|
+
body: JSON.stringify(body),
|
|
129
|
+
});
|
|
130
|
+
await throwIfNotOk(res);
|
|
131
|
+
}
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* `qagent-agent` CLI — `npx @q-agent/agent <command>`.
|
|
5
|
+
*
|
|
6
|
+
* Commands:
|
|
7
|
+
* pair <code> [--server <url>] [--name <name>] Redeem a one-time pairing code for a device token.
|
|
8
|
+
* start [--server <url>] Long-poll for jobs and run them.
|
|
9
|
+
* status Show pairing state.
|
|
10
|
+
* logout Forget the device token.
|
|
11
|
+
*/
|
|
12
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
15
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
16
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
17
|
+
}
|
|
18
|
+
Object.defineProperty(o, k2, desc);
|
|
19
|
+
}) : (function(o, m, k, k2) {
|
|
20
|
+
if (k2 === undefined) k2 = k;
|
|
21
|
+
o[k2] = m[k];
|
|
22
|
+
}));
|
|
23
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
24
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
25
|
+
}) : function(o, v) {
|
|
26
|
+
o["default"] = v;
|
|
27
|
+
});
|
|
28
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
29
|
+
var ownKeys = function(o) {
|
|
30
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
31
|
+
var ar = [];
|
|
32
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
33
|
+
return ar;
|
|
34
|
+
};
|
|
35
|
+
return ownKeys(o);
|
|
36
|
+
};
|
|
37
|
+
return function (mod) {
|
|
38
|
+
if (mod && mod.__esModule) return mod;
|
|
39
|
+
var result = {};
|
|
40
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
41
|
+
__setModuleDefault(result, mod);
|
|
42
|
+
return result;
|
|
43
|
+
};
|
|
44
|
+
})();
|
|
45
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
46
|
+
const commander_1 = require("commander");
|
|
47
|
+
const os = __importStar(require("os"));
|
|
48
|
+
const config_1 = require("./config");
|
|
49
|
+
const api_1 = require("./api");
|
|
50
|
+
const runner_1 = require("./runner");
|
|
51
|
+
const program = new commander_1.Command();
|
|
52
|
+
program.name("qagent-agent").description("Q-Agent Local Agent — runs Playwright locally for this machine's owner.");
|
|
53
|
+
program
|
|
54
|
+
.command("pair")
|
|
55
|
+
.description("Redeem a one-time pairing code (from the Q-Agent SPA) for a device token")
|
|
56
|
+
.argument("<code>", "pairing code shown in the SPA")
|
|
57
|
+
.option("--server <url>", "Q-Agent server base URL", "http://127.0.0.1:8787")
|
|
58
|
+
.option("--name <name>", "device name shown in the paired-devices list", os.hostname())
|
|
59
|
+
.action(async (code, opts) => {
|
|
60
|
+
const serverUrl = opts.server.replace(/\/+$/, "");
|
|
61
|
+
try {
|
|
62
|
+
const { deviceToken, deviceId } = await (0, api_1.redeemDevice)(serverUrl, code, opts.name);
|
|
63
|
+
const cfg = { serverUrl, deviceToken, deviceId, deviceName: opts.name };
|
|
64
|
+
(0, config_1.saveConfig)(cfg);
|
|
65
|
+
console.log(`Paired as device #${deviceId} (${opts.name}) against ${serverUrl}`);
|
|
66
|
+
console.log("Device token stored — run `qagent-agent start` to begin claiming jobs.");
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
console.error("Pairing failed:", err.message);
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
program
|
|
74
|
+
.command("start")
|
|
75
|
+
.description("Long-poll for queued jobs and run them")
|
|
76
|
+
.option("--server <url>", "override the paired server base URL for this run")
|
|
77
|
+
.action(async (opts) => {
|
|
78
|
+
const cfg = (0, config_1.loadConfig)();
|
|
79
|
+
if (!cfg) {
|
|
80
|
+
console.error("Not paired yet — run `qagent-agent pair <code>` first.");
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (opts.server)
|
|
85
|
+
cfg.serverUrl = opts.server.replace(/\/+$/, "");
|
|
86
|
+
const signal = { aborted: false };
|
|
87
|
+
const shutdown = () => {
|
|
88
|
+
console.log("\nShutting down — killing any active Playwright/capture process...");
|
|
89
|
+
signal.aborted = true;
|
|
90
|
+
(0, runner_1.killActiveChild)();
|
|
91
|
+
};
|
|
92
|
+
process.on("SIGINT", shutdown);
|
|
93
|
+
process.on("SIGTERM", shutdown);
|
|
94
|
+
await (0, runner_1.runAgentLoop)(cfg, signal);
|
|
95
|
+
});
|
|
96
|
+
program
|
|
97
|
+
.command("status")
|
|
98
|
+
.description("Show whether this machine is paired")
|
|
99
|
+
.action(() => {
|
|
100
|
+
const cfg = (0, config_1.loadConfig)();
|
|
101
|
+
if (!cfg) {
|
|
102
|
+
console.log("Not paired. Run `qagent-agent pair <code>` to pair this machine.");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
console.log(`Paired as device #${cfg.deviceId} (${cfg.deviceName})`);
|
|
106
|
+
console.log(`Server: ${cfg.serverUrl}`);
|
|
107
|
+
});
|
|
108
|
+
program
|
|
109
|
+
.command("logout")
|
|
110
|
+
.description("Forget the paired device token")
|
|
111
|
+
.action(() => {
|
|
112
|
+
(0, config_1.clearConfig)();
|
|
113
|
+
console.log("Device token cleared. Run `qagent-agent pair <code>` to pair again.");
|
|
114
|
+
});
|
|
115
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
116
|
+
console.error(err);
|
|
117
|
+
process.exitCode = 1;
|
|
118
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Local Agent config: server URL + paired device token, persisted at
|
|
4
|
+
* `~/.qagent-agent/config.json`. This is the ONLY durable credential the
|
|
5
|
+
* agent holds — a device token, never the user's app session.
|
|
6
|
+
*/
|
|
7
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
8
|
+
if (k2 === undefined) k2 = k;
|
|
9
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
10
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
11
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
12
|
+
}
|
|
13
|
+
Object.defineProperty(o, k2, desc);
|
|
14
|
+
}) : (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
o[k2] = m[k];
|
|
17
|
+
}));
|
|
18
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
19
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
20
|
+
}) : function(o, v) {
|
|
21
|
+
o["default"] = v;
|
|
22
|
+
});
|
|
23
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
24
|
+
var ownKeys = function(o) {
|
|
25
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
26
|
+
var ar = [];
|
|
27
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
28
|
+
return ar;
|
|
29
|
+
};
|
|
30
|
+
return ownKeys(o);
|
|
31
|
+
};
|
|
32
|
+
return function (mod) {
|
|
33
|
+
if (mod && mod.__esModule) return mod;
|
|
34
|
+
var result = {};
|
|
35
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
36
|
+
__setModuleDefault(result, mod);
|
|
37
|
+
return result;
|
|
38
|
+
};
|
|
39
|
+
})();
|
|
40
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
41
|
+
exports.configDir = configDir;
|
|
42
|
+
exports.loadConfig = loadConfig;
|
|
43
|
+
exports.saveConfig = saveConfig;
|
|
44
|
+
exports.clearConfig = clearConfig;
|
|
45
|
+
const fs = __importStar(require("fs"));
|
|
46
|
+
const os = __importStar(require("os"));
|
|
47
|
+
const path = __importStar(require("path"));
|
|
48
|
+
/** Directory holding the agent's persisted config + local session store. */
|
|
49
|
+
function configDir() {
|
|
50
|
+
return path.join(os.homedir(), ".qagent-agent");
|
|
51
|
+
}
|
|
52
|
+
function configPath() {
|
|
53
|
+
return path.join(configDir(), "config.json");
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Load the persisted config, or `null` if the agent has never been paired
|
|
57
|
+
* (or the file is missing/corrupt).
|
|
58
|
+
*/
|
|
59
|
+
function loadConfig() {
|
|
60
|
+
try {
|
|
61
|
+
const raw = fs.readFileSync(configPath(), "utf-8");
|
|
62
|
+
return JSON.parse(raw);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Persist `cfg`, creating the config dir if needed. The file is written
|
|
70
|
+
* with `0600` permissions (owner read/write only) since it holds a bearer
|
|
71
|
+
* token; `chmod` is best-effort since it is a no-op on Windows.
|
|
72
|
+
*/
|
|
73
|
+
function saveConfig(cfg) {
|
|
74
|
+
fs.mkdirSync(configDir(), { recursive: true });
|
|
75
|
+
const p = configPath();
|
|
76
|
+
fs.writeFileSync(p, JSON.stringify(cfg, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
77
|
+
try {
|
|
78
|
+
fs.chmodSync(p, 0o600);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Best-effort — chmod is a no-op on Windows filesystems.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Remove the persisted config (the `logout` command). */
|
|
85
|
+
function clearConfig() {
|
|
86
|
+
try {
|
|
87
|
+
fs.unlinkSync(configPath());
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// Already gone — logout is idempotent.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.ensureChromium = ensureChromium;
|
|
37
|
+
/**
|
|
38
|
+
* Ensure Playwright's Chromium build is present, downloading it once if absent —
|
|
39
|
+
* so the user never has to run `npx playwright install chromium` manually.
|
|
40
|
+
*
|
|
41
|
+
* Both the headed login capture (`vendor/capture_auth.cjs`) and the spec run use
|
|
42
|
+
* Chromium from the agent's own Playwright install; this guarantees it exists
|
|
43
|
+
* before either runs.
|
|
44
|
+
*/
|
|
45
|
+
const child_process_1 = require("child_process");
|
|
46
|
+
const fs = __importStar(require("fs"));
|
|
47
|
+
const path = __importStar(require("path"));
|
|
48
|
+
function firstExisting(paths) {
|
|
49
|
+
for (const p of paths) {
|
|
50
|
+
if (fs.existsSync(p))
|
|
51
|
+
return p;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
/** The agent package's own node_modules (mirrors runner.ts's resolver). */
|
|
56
|
+
function agentNodeModules() {
|
|
57
|
+
return (firstExisting([path.join(__dirname, "..", "..", "node_modules"), path.join(__dirname, "..", "node_modules")]) ??
|
|
58
|
+
path.join(__dirname, "..", "..", "node_modules"));
|
|
59
|
+
}
|
|
60
|
+
/** Path Playwright expects the Chromium build at, or null if it can't be resolved. */
|
|
61
|
+
function chromiumExecutable() {
|
|
62
|
+
try {
|
|
63
|
+
// Resolved from the agent's own node_modules — the same install used to run specs.
|
|
64
|
+
const { chromium } = require("playwright");
|
|
65
|
+
return chromium.executablePath();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Ensure Chromium is installed, running `playwright install chromium` if it's missing.
|
|
73
|
+
*
|
|
74
|
+
* Fast no-op once the browser exists. Streams the download progress so a first-run
|
|
75
|
+
* fetch (~100 MB) is visible rather than a silent hang.
|
|
76
|
+
*
|
|
77
|
+
* Returns:
|
|
78
|
+
* true when Chromium is available (already present, or installed successfully);
|
|
79
|
+
* false when the install failed — the caller should abort rather than run.
|
|
80
|
+
*/
|
|
81
|
+
async function ensureChromium() {
|
|
82
|
+
const exe = chromiumExecutable();
|
|
83
|
+
if (exe && fs.existsSync(exe))
|
|
84
|
+
return true;
|
|
85
|
+
console.log("Chromium not found — installing Playwright's Chromium (one-time download)...");
|
|
86
|
+
const nm = agentNodeModules();
|
|
87
|
+
const bin = path.join(nm, ".bin", process.platform === "win32" ? "playwright.cmd" : "playwright");
|
|
88
|
+
const useBin = fs.existsSync(bin);
|
|
89
|
+
const cmd = useBin ? bin : "npx";
|
|
90
|
+
const args = useBin ? ["install", "chromium"] : ["playwright", "install", "chromium"];
|
|
91
|
+
const code = await new Promise((resolve) => {
|
|
92
|
+
const child = (0, child_process_1.spawn)(cmd, args, { stdio: "inherit", shell: process.platform === "win32" });
|
|
93
|
+
child.on("close", resolve);
|
|
94
|
+
child.on("error", () => resolve(null));
|
|
95
|
+
});
|
|
96
|
+
if (code !== 0) {
|
|
97
|
+
console.error("Chromium install failed — run `npx playwright install chromium` manually, then retry.");
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
console.log("Chromium ready.");
|
|
101
|
+
return true;
|
|
102
|
+
}
|