@q-agent/agent 0.1.8 → 0.1.10
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/dist/src/api.js +63 -0
- package/dist/src/paths.js +13 -0
- package/dist/src/runner.js +310 -0
- package/dist/test/explore.test.js +265 -0
- package/package.json +1 -1
- package/vendor/explore_session.cjs +190 -0
package/dist/src/api.js
CHANGED
|
@@ -51,6 +51,10 @@ exports.postResult = postResult;
|
|
|
51
51
|
exports.postEvidence = postEvidence;
|
|
52
52
|
exports.postHealFix = postHealFix;
|
|
53
53
|
exports.postHealFinalize = postHealFinalize;
|
|
54
|
+
exports.claimNextExploration = claimNextExploration;
|
|
55
|
+
exports.postExploreDecide = postExploreDecide;
|
|
56
|
+
exports.postExploreEvent = postExploreEvent;
|
|
57
|
+
exports.postExploreFinalize = postExploreFinalize;
|
|
54
58
|
exports.postComplete = postComplete;
|
|
55
59
|
const fs = __importStar(require("fs"));
|
|
56
60
|
/** Raised for any non-2xx/204 response; carries the HTTP status for callers that care (e.g. 409). */
|
|
@@ -216,6 +220,65 @@ async function postHealFinalize(cfg, caseId, body) {
|
|
|
216
220
|
});
|
|
217
221
|
await throwIfNotOk(res);
|
|
218
222
|
}
|
|
223
|
+
/** Claim the next queued exploration session for this device's owner. `null` on 204. */
|
|
224
|
+
async function claimNextExploration(cfg) {
|
|
225
|
+
const res = await fetch(`${cfg.serverUrl}/agent/explore/next`, {
|
|
226
|
+
method: "POST",
|
|
227
|
+
headers: authHeaders(cfg.deviceToken),
|
|
228
|
+
});
|
|
229
|
+
if (res.status === 204)
|
|
230
|
+
return null;
|
|
231
|
+
await throwIfNotOk(res);
|
|
232
|
+
return (await res.json());
|
|
233
|
+
}
|
|
234
|
+
/** Ask the server to decide the next exploration step (Claude + budget live
|
|
235
|
+
* server-side). Starts an async job then polls for the result — same async
|
|
236
|
+
* start-then-poll shape as {@link postHealFix} so a long decide never trips the
|
|
237
|
+
* ~100s proxy edge cap. */
|
|
238
|
+
async function postExploreDecide(cfg, sessionId, body) {
|
|
239
|
+
const startRes = await fetchWithTimeout(`${cfg.serverUrl}/agent/explore/${sessionId}/decide`, {
|
|
240
|
+
method: "POST",
|
|
241
|
+
headers: { ...authHeaders(cfg.deviceToken), "Content-Type": "application/json" },
|
|
242
|
+
body: JSON.stringify(body),
|
|
243
|
+
}, HEAL_FIX_REQUEST_TIMEOUT_MS);
|
|
244
|
+
await throwIfNotOk(startRes);
|
|
245
|
+
const { jobId } = (await startRes.json());
|
|
246
|
+
if (!jobId)
|
|
247
|
+
throw new Error("Explore decide did not return a job id");
|
|
248
|
+
const deadline = Date.now() + HEAL_FIX_TOTAL_TIMEOUT_MS;
|
|
249
|
+
for (;;) {
|
|
250
|
+
const res = await fetchWithTimeout(`${cfg.serverUrl}/agent/explore/${sessionId}/decide/${jobId}`, { method: "GET", headers: authHeaders(cfg.deviceToken) }, HEAL_FIX_REQUEST_TIMEOUT_MS);
|
|
251
|
+
await throwIfNotOk(res);
|
|
252
|
+
const data = (await res.json());
|
|
253
|
+
if (data.status === "done" && data.result)
|
|
254
|
+
return data.result;
|
|
255
|
+
if (data.status === "error")
|
|
256
|
+
throw new Error(data.error || "Explore decide failed on the server");
|
|
257
|
+
if (Date.now() > deadline)
|
|
258
|
+
throw new Error("Explore decide timed out waiting for the server");
|
|
259
|
+
await sleep(HEAL_FIX_POLL_INTERVAL_MS);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** Push one exploration progress event; the server relays it to the run WS
|
|
263
|
+
* (`explore.progress`) when the session carries a runId. */
|
|
264
|
+
async function postExploreEvent(cfg, sessionId, event, payload) {
|
|
265
|
+
const res = await fetch(`${cfg.serverUrl}/agent/explore/${sessionId}/events`, {
|
|
266
|
+
method: "POST",
|
|
267
|
+
headers: { ...authHeaders(cfg.deviceToken), "Content-Type": "application/json" },
|
|
268
|
+
body: JSON.stringify({ event, payload }),
|
|
269
|
+
});
|
|
270
|
+
await throwIfNotOk(res);
|
|
271
|
+
}
|
|
272
|
+
/** Finalize an exploration session: the server KB-merges the observed
|
|
273
|
+
* discovery (`merge_verified_discovery`) and stores the terminal result. */
|
|
274
|
+
async function postExploreFinalize(cfg, sessionId, body) {
|
|
275
|
+
const res = await fetch(`${cfg.serverUrl}/agent/explore/${sessionId}/finalize`, {
|
|
276
|
+
method: "POST",
|
|
277
|
+
headers: { ...authHeaders(cfg.deviceToken), "Content-Type": "application/json" },
|
|
278
|
+
body: JSON.stringify(body),
|
|
279
|
+
});
|
|
280
|
+
await throwIfNotOk(res);
|
|
281
|
+
}
|
|
219
282
|
/** Finalize an execution with its aggregate counts + captured log tail. */
|
|
220
283
|
async function postComplete(cfg, executionId, body) {
|
|
221
284
|
const res = await fetch(`${cfg.serverUrl}/agent/jobs/${executionId}/complete`, {
|
package/dist/src/paths.js
CHANGED
|
@@ -41,6 +41,7 @@ exports.nodeBin = nodeBin;
|
|
|
41
41
|
exports.agentNodeModules = agentNodeModules;
|
|
42
42
|
exports.playwrightCli = playwrightCli;
|
|
43
43
|
exports.vendorCaptureScript = vendorCaptureScript;
|
|
44
|
+
exports.vendorExploreScript = vendorExploreScript;
|
|
44
45
|
/**
|
|
45
46
|
* Runtime path resolution for the agent's child processes (Playwright CLI +
|
|
46
47
|
* the headed-login capture script), working in three layouts:
|
|
@@ -150,3 +151,15 @@ function vendorCaptureScript() {
|
|
|
150
151
|
throw new Error("capture_auth.cjs not found — the agent package is missing vendor/");
|
|
151
152
|
return found;
|
|
152
153
|
}
|
|
154
|
+
/** The vendored persistent DOM-exploration driver (`explore_session.cjs`). */
|
|
155
|
+
function vendorExploreScript() {
|
|
156
|
+
const root = packagedRoot();
|
|
157
|
+
const found = firstExisting([
|
|
158
|
+
...(root ? [path.join(root, "vendor", "explore_session.cjs")] : []),
|
|
159
|
+
path.join(__dirname, "..", "..", "vendor", "explore_session.cjs"),
|
|
160
|
+
path.join(__dirname, "..", "vendor", "explore_session.cjs"),
|
|
161
|
+
]);
|
|
162
|
+
if (!found)
|
|
163
|
+
throw new Error("explore_session.cjs not found — the agent package is missing vendor/");
|
|
164
|
+
return found;
|
|
165
|
+
}
|
package/dist/src/runner.js
CHANGED
|
@@ -42,11 +42,14 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
42
42
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
43
43
|
exports.killActiveChild = killActiveChild;
|
|
44
44
|
exports.processJob = processJob;
|
|
45
|
+
exports.runExplorationLoop = runExplorationLoop;
|
|
46
|
+
exports.processExplorationJob = processExplorationJob;
|
|
45
47
|
exports.runAgentLoop = runAgentLoop;
|
|
46
48
|
const child_process_1 = require("child_process");
|
|
47
49
|
const fs = __importStar(require("fs"));
|
|
48
50
|
const os = __importStar(require("os"));
|
|
49
51
|
const path = __importStar(require("path"));
|
|
52
|
+
const readline = __importStar(require("readline"));
|
|
50
53
|
const api = __importStar(require("./api"));
|
|
51
54
|
const bus_1 = require("./bus");
|
|
52
55
|
const ensureBrowser_1 = require("./ensureBrowser");
|
|
@@ -607,6 +610,292 @@ async function processHealJob(cfg, job, heal) {
|
|
|
607
610
|
fs.rmSync(workDir, { recursive: true, force: true });
|
|
608
611
|
}
|
|
609
612
|
}
|
|
613
|
+
/** Actions that mutate app/server state — gated by `allowStateChanging`. `goto`
|
|
614
|
+
* (navigation) and `expectVisible` (a pure probe) are always allowed. */
|
|
615
|
+
const STATE_CHANGING_ACTIONS = new Set(["click", "fill"]);
|
|
616
|
+
/** How long to wait for any single driver command before giving up on it (a
|
|
617
|
+
* hung observe/act must not stall the whole loop; the loop tolerates the error
|
|
618
|
+
* and finalizes). Generous because a `goto` can load a slow page. */
|
|
619
|
+
const EXPLORE_CMD_TIMEOUT_MS = 120_000;
|
|
620
|
+
/** The `{strategy,value}` of a locator, for the discovered-selectors log. */
|
|
621
|
+
function describeSelector(args) {
|
|
622
|
+
if (!args)
|
|
623
|
+
return null;
|
|
624
|
+
if (args.testId)
|
|
625
|
+
return { strategy: "testId", value: args.testId };
|
|
626
|
+
if (args.role)
|
|
627
|
+
return { strategy: "role", value: args.role, name: args.name };
|
|
628
|
+
if (args.selector)
|
|
629
|
+
return { strategy: "selector", value: args.selector };
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* The pure exploration loop, decoupled from the subprocess/HTTP so it can be
|
|
634
|
+
* unit-tested with fakes. Observes → asks the server to decide → acts, up to
|
|
635
|
+
* `maxSteps`, stopping on `done`, on a server `stop` (e.g. budget), or on
|
|
636
|
+
* repeat detection (a url+a11y signature seen before). A gated state-changing
|
|
637
|
+
* action is SKIPPED (recorded, not executed). Never throws: any error is logged
|
|
638
|
+
* and the session is finalized with whatever was accumulated.
|
|
639
|
+
*/
|
|
640
|
+
async function runExplorationLoop(cfg, session, driver, apiClient, emitFn) {
|
|
641
|
+
const history = [];
|
|
642
|
+
const log = [];
|
|
643
|
+
const discovered = { routes: [], selectors: [] };
|
|
644
|
+
const seen = new Set();
|
|
645
|
+
let stopReason = "maxSteps";
|
|
646
|
+
let stepsTaken = 0;
|
|
647
|
+
try {
|
|
648
|
+
for (let step = 0; step < session.maxSteps; step++) {
|
|
649
|
+
const obs = await driver.observe();
|
|
650
|
+
if (!obs || !obs.ok) {
|
|
651
|
+
stopReason = "observe-error";
|
|
652
|
+
log.push({ step, phase: "observe", error: (obs && obs.error) || "observe failed" });
|
|
653
|
+
break;
|
|
654
|
+
}
|
|
655
|
+
// Repeat detection: identical url + a11y snapshot means we're looping.
|
|
656
|
+
const signature = `${obs.url}::${JSON.stringify(obs.a11y)}`;
|
|
657
|
+
if (seen.has(signature)) {
|
|
658
|
+
stopReason = "repeat";
|
|
659
|
+
log.push({ step, phase: "repeat", url: obs.url });
|
|
660
|
+
break;
|
|
661
|
+
}
|
|
662
|
+
seen.add(signature);
|
|
663
|
+
let decision;
|
|
664
|
+
try {
|
|
665
|
+
decision = await apiClient.postExploreDecide(cfg, session.sessionId, {
|
|
666
|
+
observation: { accessibility: obs.a11y, elements: obs.elements, url: obs.url, path: obs.path },
|
|
667
|
+
history,
|
|
668
|
+
stepsTaken,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
catch (err) {
|
|
672
|
+
stopReason = "decide-error";
|
|
673
|
+
log.push({ step, phase: "decide", error: err.message });
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
history.push({ action: decision.action, args: decision.args, reasoning: decision.reasoning });
|
|
677
|
+
if (decision.stop || decision.action === "done") {
|
|
678
|
+
stopReason = decision.action === "done" ? "done" : decision.stopReason || "stop";
|
|
679
|
+
log.push({ step, action: decision.action, reasoning: decision.reasoning, stop: true, stopReason });
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
const gated = STATE_CHANGING_ACTIONS.has(decision.action) && !session.allowStateChanging;
|
|
683
|
+
const rec = {
|
|
684
|
+
step,
|
|
685
|
+
action: decision.action,
|
|
686
|
+
args: decision.args,
|
|
687
|
+
reasoning: decision.reasoning,
|
|
688
|
+
};
|
|
689
|
+
if (gated) {
|
|
690
|
+
// Do NOT execute a state-changing action when it isn't allowed — record
|
|
691
|
+
// the skip and move on so the server sees it was considered.
|
|
692
|
+
rec.skipped = true;
|
|
693
|
+
rec.skipReason = "state-changing action gated (allowStateChanging=false)";
|
|
694
|
+
log.push(rec);
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
697
|
+
let result;
|
|
698
|
+
try {
|
|
699
|
+
result = await driver.act(decision.action, decision.args || {});
|
|
700
|
+
}
|
|
701
|
+
catch (err) {
|
|
702
|
+
result = { ok: false, error: err.message, changed: false };
|
|
703
|
+
}
|
|
704
|
+
rec.result = result;
|
|
705
|
+
log.push(rec);
|
|
706
|
+
if (result.ok) {
|
|
707
|
+
// Record the route/selector that actually worked (with its strategy).
|
|
708
|
+
if (decision.action === "goto") {
|
|
709
|
+
const route = (decision.args && decision.args.url) || obs.path || obs.url;
|
|
710
|
+
if (route)
|
|
711
|
+
discovered.routes.push(route);
|
|
712
|
+
}
|
|
713
|
+
else {
|
|
714
|
+
const sel = describeSelector(decision.args);
|
|
715
|
+
if (sel)
|
|
716
|
+
discovered.selectors.push({ action: decision.action, ...sel });
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
stepsTaken++;
|
|
721
|
+
const progress = {
|
|
722
|
+
step: step + 1,
|
|
723
|
+
action: decision.action,
|
|
724
|
+
reasoning: decision.reasoning,
|
|
725
|
+
skipped: gated,
|
|
726
|
+
stepsTaken,
|
|
727
|
+
maxSteps: session.maxSteps,
|
|
728
|
+
};
|
|
729
|
+
await apiClient.postExploreEvent(cfg, session.sessionId, "explore.progress", progress).catch(() => { });
|
|
730
|
+
emitFn("explore-progress", progress);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
catch (err) {
|
|
734
|
+
// Belt-and-braces: the loop already guards each awaited step, but never let
|
|
735
|
+
// an unexpected throw escape — finalize with what we have.
|
|
736
|
+
stopReason = "error";
|
|
737
|
+
log.push({ phase: "loop", error: err.message });
|
|
738
|
+
}
|
|
739
|
+
finally {
|
|
740
|
+
try {
|
|
741
|
+
await driver.close();
|
|
742
|
+
}
|
|
743
|
+
catch {
|
|
744
|
+
// Driver already gone.
|
|
745
|
+
}
|
|
746
|
+
await apiClient
|
|
747
|
+
.postExploreFinalize(cfg, session.sessionId, { discovered, log, stopReason, stepsTaken })
|
|
748
|
+
.catch((err) => console.error("postExploreFinalize failed:", err));
|
|
749
|
+
emitFn("explore-complete", { sessionId: session.sessionId, stopReason, stepsTaken });
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Wrap the persistent explore_session.cjs subprocess in an {@link ExploreDriver}:
|
|
754
|
+
* a serialized newline-delimited-JSON request/response channel. The driver emits
|
|
755
|
+
* one unsolicited `{ok,ready}` line at startup (consumed by {@link ExploreDriver.observe}'s
|
|
756
|
+
* first caller via `ready()`); thereafter it is strictly one response line per
|
|
757
|
+
* command. Any command that outlives {@link EXPLORE_CMD_TIMEOUT_MS} or a dead
|
|
758
|
+
* process resolves with an error object rather than hanging.
|
|
759
|
+
*/
|
|
760
|
+
function makeExploreDriver(child) {
|
|
761
|
+
const rl = readline.createInterface({ input: child.stdout });
|
|
762
|
+
const pending = [];
|
|
763
|
+
let gotReady = false;
|
|
764
|
+
let readyResolve;
|
|
765
|
+
const readyPromise = new Promise((r) => {
|
|
766
|
+
readyResolve = r;
|
|
767
|
+
});
|
|
768
|
+
rl.on("line", (line) => {
|
|
769
|
+
if (!line.trim())
|
|
770
|
+
return;
|
|
771
|
+
let obj;
|
|
772
|
+
try {
|
|
773
|
+
obj = JSON.parse(line);
|
|
774
|
+
}
|
|
775
|
+
catch {
|
|
776
|
+
return; // ignore non-JSON diagnostic lines
|
|
777
|
+
}
|
|
778
|
+
if (!gotReady) {
|
|
779
|
+
gotReady = true;
|
|
780
|
+
readyResolve(obj);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
const resolve = pending.shift();
|
|
784
|
+
if (resolve)
|
|
785
|
+
resolve(obj);
|
|
786
|
+
});
|
|
787
|
+
const failAll = () => {
|
|
788
|
+
if (!gotReady) {
|
|
789
|
+
gotReady = true;
|
|
790
|
+
readyResolve({ ok: false, error: "driver exited before ready" });
|
|
791
|
+
}
|
|
792
|
+
while (pending.length) {
|
|
793
|
+
const resolve = pending.shift();
|
|
794
|
+
if (resolve)
|
|
795
|
+
resolve({ ok: false, error: "driver process closed" });
|
|
796
|
+
}
|
|
797
|
+
};
|
|
798
|
+
child.on("close", failAll);
|
|
799
|
+
child.on("error", failAll);
|
|
800
|
+
const request = (cmd) => new Promise((resolve) => {
|
|
801
|
+
const timer = setTimeout(() => {
|
|
802
|
+
const idx = pending.indexOf(wrapped);
|
|
803
|
+
if (idx >= 0)
|
|
804
|
+
pending.splice(idx, 1);
|
|
805
|
+
resolve({ ok: false, error: "driver command timed out" });
|
|
806
|
+
}, EXPLORE_CMD_TIMEOUT_MS);
|
|
807
|
+
const wrapped = (v) => {
|
|
808
|
+
clearTimeout(timer);
|
|
809
|
+
resolve(v);
|
|
810
|
+
};
|
|
811
|
+
pending.push(wrapped);
|
|
812
|
+
try {
|
|
813
|
+
child.stdin.write(JSON.stringify(cmd) + "\n");
|
|
814
|
+
}
|
|
815
|
+
catch {
|
|
816
|
+
// stdin closed — the close/error handler will resolve pending.
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
return {
|
|
820
|
+
ready: () => readyPromise,
|
|
821
|
+
observe: () => request({ cmd: "observe" }),
|
|
822
|
+
act: (action, args) => request({ cmd: "act", action, args }),
|
|
823
|
+
close: async () => {
|
|
824
|
+
try {
|
|
825
|
+
child.stdin.write(JSON.stringify({ cmd: "close" }) + "\n");
|
|
826
|
+
}
|
|
827
|
+
catch {
|
|
828
|
+
// already gone
|
|
829
|
+
}
|
|
830
|
+
},
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Process one claimed exploration session end-to-end (#338): reuse a saved
|
|
835
|
+
* local session for the origin (auth never leaves this machine), spawn the
|
|
836
|
+
* vendored persistent Playwright driver, run the observe→decide→act loop, then
|
|
837
|
+
* finalize. The driver subprocess is ALWAYS killed in `finally`.
|
|
838
|
+
*/
|
|
839
|
+
async function processExplorationJob(cfg, session) {
|
|
840
|
+
// Reuse a captured session for the origin if one exists (exploration never
|
|
841
|
+
// prompts for a headed login — it explores with whatever auth is available).
|
|
842
|
+
let origin = session.origin;
|
|
843
|
+
if (!origin && session.baseUrl) {
|
|
844
|
+
try {
|
|
845
|
+
origin = new URL(session.baseUrl).origin;
|
|
846
|
+
}
|
|
847
|
+
catch {
|
|
848
|
+
origin = "";
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
const storageState = origin && (0, session_1.hasValidSession)(origin) ? (0, session_1.sessionPathsForOrigin)(origin).storageStatePath : "";
|
|
852
|
+
const script = (0, paths_1.vendorExploreScript)();
|
|
853
|
+
const nm = (0, paths_1.agentNodeModules)();
|
|
854
|
+
const args = [script, session.baseUrl];
|
|
855
|
+
if (storageState)
|
|
856
|
+
args.push(storageState);
|
|
857
|
+
const child = (0, child_process_1.spawn)((0, paths_1.nodeBin)(), args, { cwd: nm, env: nodePathEnv(nm) });
|
|
858
|
+
activeChild = child;
|
|
859
|
+
// Capture the driver's stderr so a launch failure (missing browser, bad
|
|
860
|
+
// Playwright resolution, …) is visible instead of a silent "complete".
|
|
861
|
+
let driverStderr = "";
|
|
862
|
+
child.stderr?.on("data", (d) => {
|
|
863
|
+
driverStderr += String(d);
|
|
864
|
+
});
|
|
865
|
+
const driver = makeExploreDriver(child);
|
|
866
|
+
try {
|
|
867
|
+
// Wait for the driver's readiness signal before the first observe. If the
|
|
868
|
+
// driver died before signalling ready, finalize with a clear driver-error
|
|
869
|
+
// (carrying its stderr) rather than letting the loop break silently.
|
|
870
|
+
const ready = await driver.ready();
|
|
871
|
+
if (!ready || ready.ok === false) {
|
|
872
|
+
const error = (ready && ready.error) || "driver failed to start";
|
|
873
|
+
const detail = driverStderr.trim();
|
|
874
|
+
await api
|
|
875
|
+
.postExploreFinalize(cfg, session.sessionId, {
|
|
876
|
+
discovered: { routes: [], selectors: [] },
|
|
877
|
+
log: [{ phase: "driver", error, stderr: detail || undefined }],
|
|
878
|
+
stopReason: "driver-error",
|
|
879
|
+
stepsTaken: 0,
|
|
880
|
+
})
|
|
881
|
+
.catch((err) => console.error("postExploreFinalize failed:", err));
|
|
882
|
+
(0, bus_1.emit)("explore-complete", { sessionId: session.sessionId, stopReason: "driver-error", error });
|
|
883
|
+
console.error(`Exploration ${session.sessionId} driver-error: ${error}${detail ? ` — ${detail}` : ""}`);
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
await runExplorationLoop(cfg, session, driver, api, bus_1.emit);
|
|
887
|
+
}
|
|
888
|
+
finally {
|
|
889
|
+
try {
|
|
890
|
+
child.kill();
|
|
891
|
+
}
|
|
892
|
+
catch {
|
|
893
|
+
// Already gone.
|
|
894
|
+
}
|
|
895
|
+
if (activeChild === child)
|
|
896
|
+
activeChild = null;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
610
899
|
/**
|
|
611
900
|
* Long-poll loop: claim → process → repeat, backing off `IDLE_POLL_MS`
|
|
612
901
|
* between empty claims. Runs until `signal.aborted`.
|
|
@@ -638,6 +927,27 @@ async function runAgentLoop(cfg, signal) {
|
|
|
638
927
|
await processCapture(cfg, capture);
|
|
639
928
|
continue;
|
|
640
929
|
}
|
|
930
|
+
// No capture either — check for a queued DOM-exploration session (#338).
|
|
931
|
+
let explore = null;
|
|
932
|
+
try {
|
|
933
|
+
explore = await api.claimNextExploration(cfg);
|
|
934
|
+
}
|
|
935
|
+
catch (err) {
|
|
936
|
+
console.error("Exploration claim failed:", err.message);
|
|
937
|
+
}
|
|
938
|
+
if (explore) {
|
|
939
|
+
console.log(`Claimed exploration ${explore.sessionId} (${explore.target?.goal || explore.baseUrl})`);
|
|
940
|
+
(0, bus_1.emit)("explore-claimed", { sessionId: explore.sessionId, goal: explore.target?.goal });
|
|
941
|
+
try {
|
|
942
|
+
await processExplorationJob(cfg, explore);
|
|
943
|
+
console.log(`Exploration ${explore.sessionId} complete`);
|
|
944
|
+
}
|
|
945
|
+
catch (err) {
|
|
946
|
+
console.error(`Exploration ${explore.sessionId} crashed:`, err);
|
|
947
|
+
(0, bus_1.emit)("error", { message: `Exploration ${explore.sessionId} crashed: ${err.message}` });
|
|
948
|
+
}
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
641
951
|
await new Promise((r) => setTimeout(r, IDLE_POLL_MS));
|
|
642
952
|
continue;
|
|
643
953
|
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Tests for the DOM-exploration slice (#338):
|
|
4
|
+
* - `runExplorationLoop` (runner.ts) with the driver + HTTP client MOCKED
|
|
5
|
+
* (injected fakes) — asserts the loop stops on `done`, on a server
|
|
6
|
+
* `stop:budget`, on `maxSteps`, and on repeat detection; that a gated
|
|
7
|
+
* state-changing action is SKIPPED (not executed); and that finalize is
|
|
8
|
+
* called with the accumulated discovered/log.
|
|
9
|
+
* - the new `api.ts` explore client calls with `fetch` mocked (same style as
|
|
10
|
+
* api.test.ts) — request shape + the decide start-then-poll flow.
|
|
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
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
46
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
47
|
+
};
|
|
48
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
49
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
50
|
+
const node_test_1 = require("node:test");
|
|
51
|
+
const api = __importStar(require("../src/api"));
|
|
52
|
+
const runner_1 = require("../src/runner");
|
|
53
|
+
const cfg = {
|
|
54
|
+
serverUrl: "http://127.0.0.1:8787",
|
|
55
|
+
deviceToken: "test-token",
|
|
56
|
+
deviceId: 1,
|
|
57
|
+
deviceName: "test-machine",
|
|
58
|
+
};
|
|
59
|
+
function makeSession(overrides = {}) {
|
|
60
|
+
return {
|
|
61
|
+
sessionId: "S1",
|
|
62
|
+
baseUrl: "https://app.example.com",
|
|
63
|
+
origin: "https://app.example.com",
|
|
64
|
+
target: { ticket: "T1", screen: "Home", goal: "explore home" },
|
|
65
|
+
maxSteps: 5,
|
|
66
|
+
allowStateChanging: true,
|
|
67
|
+
projectKey: "P",
|
|
68
|
+
repo: "r",
|
|
69
|
+
...overrides,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** A fake driver. By default each `observe` returns a DISTINCT page (so repeat
|
|
73
|
+
* detection never fires); pass `staticObs:true` to return an identical page. */
|
|
74
|
+
function makeFakeDriver(opts = {}) {
|
|
75
|
+
let n = 0;
|
|
76
|
+
const acts = [];
|
|
77
|
+
let closed = false;
|
|
78
|
+
const driver = {
|
|
79
|
+
observe: async () => {
|
|
80
|
+
const i = opts.staticObs ? 0 : ++n;
|
|
81
|
+
return { ok: true, url: `https://app.example.com/p${i}`, path: `/p${i}`, a11y: { i }, elements: [] };
|
|
82
|
+
},
|
|
83
|
+
act: async (action, args) => {
|
|
84
|
+
acts.push({ action, args });
|
|
85
|
+
return { ok: true, error: null, changed: true };
|
|
86
|
+
},
|
|
87
|
+
close: async () => {
|
|
88
|
+
closed = true;
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
return { driver, acts, isClosed: () => closed };
|
|
92
|
+
}
|
|
93
|
+
/** A fake explore HTTP client that returns scripted decisions in order (the
|
|
94
|
+
* last one repeats if the loop asks for more). Records events + finalize. */
|
|
95
|
+
function makeFakeApi(decisions) {
|
|
96
|
+
let di = 0;
|
|
97
|
+
const events = [];
|
|
98
|
+
const finalizeCalls = [];
|
|
99
|
+
const client = {
|
|
100
|
+
postExploreDecide: async () => decisions[Math.min(di++, decisions.length - 1)],
|
|
101
|
+
postExploreEvent: async (_cfg, _sid, event, payload) => {
|
|
102
|
+
events.push({ event, payload });
|
|
103
|
+
},
|
|
104
|
+
postExploreFinalize: async (_cfg, _sid, body) => {
|
|
105
|
+
finalizeCalls.push(body);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
return { client, events, finalizeCalls, decideCount: () => di };
|
|
109
|
+
}
|
|
110
|
+
const noopEmit = () => { };
|
|
111
|
+
(0, node_test_1.test)("loop stops on a `done` action and finalizes with stopReason=done", async () => {
|
|
112
|
+
const { driver, acts, isClosed } = makeFakeDriver();
|
|
113
|
+
const fake = makeFakeApi([
|
|
114
|
+
{ action: "click", args: { testId: "menu" }, reasoning: "open menu" },
|
|
115
|
+
{ action: "done", args: {}, reasoning: "goal reached" },
|
|
116
|
+
]);
|
|
117
|
+
await (0, runner_1.runExplorationLoop)(cfg, makeSession(), driver, fake.client, noopEmit);
|
|
118
|
+
strict_1.default.equal(fake.finalizeCalls.length, 1);
|
|
119
|
+
const body = fake.finalizeCalls[0];
|
|
120
|
+
strict_1.default.equal(body.stopReason, "done");
|
|
121
|
+
strict_1.default.equal(body.stepsTaken, 1); // one click executed, then `done` broke the loop
|
|
122
|
+
strict_1.default.deepEqual(acts, [{ action: "click", args: { testId: "menu" } }]);
|
|
123
|
+
// Discovered records the selector that worked, with its strategy.
|
|
124
|
+
const discovered = body.discovered;
|
|
125
|
+
strict_1.default.deepEqual(discovered.selectors, [{ action: "click", strategy: "testId", value: "menu" }]);
|
|
126
|
+
strict_1.default.ok(isClosed(), "driver.close() should be called");
|
|
127
|
+
});
|
|
128
|
+
(0, node_test_1.test)("loop stops when the server returns stop:true (budget)", async () => {
|
|
129
|
+
const { driver } = makeFakeDriver();
|
|
130
|
+
const fake = makeFakeApi([
|
|
131
|
+
{ action: "click", args: { selector: ".a" }, reasoning: "step 1" },
|
|
132
|
+
{ action: "noop", args: {}, reasoning: "budget spent", stop: true, stopReason: "budget" },
|
|
133
|
+
]);
|
|
134
|
+
await (0, runner_1.runExplorationLoop)(cfg, makeSession(), driver, fake.client, noopEmit);
|
|
135
|
+
strict_1.default.equal(fake.finalizeCalls[0].stopReason, "budget");
|
|
136
|
+
strict_1.default.equal(fake.finalizeCalls[0].stepsTaken, 1);
|
|
137
|
+
});
|
|
138
|
+
(0, node_test_1.test)("loop stops at maxSteps when nothing else stops it", async () => {
|
|
139
|
+
const { driver, acts } = makeFakeDriver();
|
|
140
|
+
const fake = makeFakeApi([{ action: "click", args: { role: "button", name: "Next" }, reasoning: "keep going" }]);
|
|
141
|
+
await (0, runner_1.runExplorationLoop)(cfg, makeSession({ maxSteps: 3 }), driver, fake.client, noopEmit);
|
|
142
|
+
strict_1.default.equal(fake.finalizeCalls[0].stopReason, "maxSteps");
|
|
143
|
+
strict_1.default.equal(fake.finalizeCalls[0].stepsTaken, 3);
|
|
144
|
+
strict_1.default.equal(acts.length, 3);
|
|
145
|
+
});
|
|
146
|
+
(0, node_test_1.test)("loop stops on repeat detection (same url + a11y signature)", async () => {
|
|
147
|
+
const { driver } = makeFakeDriver({ staticObs: true });
|
|
148
|
+
const fake = makeFakeApi([{ action: "click", args: { selector: ".x" }, reasoning: "click" }]);
|
|
149
|
+
await (0, runner_1.runExplorationLoop)(cfg, makeSession(), driver, fake.client, noopEmit);
|
|
150
|
+
strict_1.default.equal(fake.finalizeCalls[0].stopReason, "repeat");
|
|
151
|
+
strict_1.default.equal(fake.finalizeCalls[0].stepsTaken, 1); // first step ran, second observe repeated
|
|
152
|
+
});
|
|
153
|
+
(0, node_test_1.test)("a gated state-changing action is SKIPPED, not executed", async () => {
|
|
154
|
+
const { driver, acts } = makeFakeDriver();
|
|
155
|
+
const fake = makeFakeApi([{ action: "click", args: { testId: "buy" }, reasoning: "attempt purchase" }]);
|
|
156
|
+
await (0, runner_1.runExplorationLoop)(cfg, makeSession({ maxSteps: 1, allowStateChanging: false }), driver, fake.client, noopEmit);
|
|
157
|
+
strict_1.default.equal(acts.length, 0, "driver.act must not be called for a gated action");
|
|
158
|
+
const body = fake.finalizeCalls[0];
|
|
159
|
+
const log = body.log;
|
|
160
|
+
const clickEntry = log.find((e) => e.action === "click");
|
|
161
|
+
strict_1.default.ok(clickEntry, "the skipped action is still recorded in the log");
|
|
162
|
+
strict_1.default.equal(clickEntry?.skipped, true);
|
|
163
|
+
// Nothing was actually exercised, so no discovery is recorded.
|
|
164
|
+
const discovered = body.discovered;
|
|
165
|
+
strict_1.default.equal(discovered.selectors.length, 0);
|
|
166
|
+
});
|
|
167
|
+
(0, node_test_1.test)("goto action records the route in discovered", async () => {
|
|
168
|
+
const { driver } = makeFakeDriver();
|
|
169
|
+
const fake = makeFakeApi([
|
|
170
|
+
{ action: "goto", args: { url: "/settings" }, reasoning: "navigate" },
|
|
171
|
+
{ action: "done", args: {}, reasoning: "done" },
|
|
172
|
+
]);
|
|
173
|
+
await (0, runner_1.runExplorationLoop)(cfg, makeSession(), driver, fake.client, noopEmit);
|
|
174
|
+
const discovered = fake.finalizeCalls[0].discovered;
|
|
175
|
+
strict_1.default.deepEqual(discovered.routes, ["/settings"]);
|
|
176
|
+
});
|
|
177
|
+
(0, node_test_1.test)("progress is emitted via both postExploreEvent and the local bus", async () => {
|
|
178
|
+
const { driver } = makeFakeDriver();
|
|
179
|
+
const fake = makeFakeApi([
|
|
180
|
+
{ action: "click", args: { testId: "a" }, reasoning: "r" },
|
|
181
|
+
{ action: "done", args: {}, reasoning: "done" },
|
|
182
|
+
]);
|
|
183
|
+
const emitted = [];
|
|
184
|
+
await (0, runner_1.runExplorationLoop)(cfg, makeSession(), driver, fake.client, (type, data) => emitted.push({ type, data }));
|
|
185
|
+
strict_1.default.equal(fake.events.length, 1);
|
|
186
|
+
strict_1.default.equal(fake.events[0].event, "explore.progress");
|
|
187
|
+
strict_1.default.ok(emitted.some((e) => e.type === "explore-progress"));
|
|
188
|
+
strict_1.default.ok(emitted.some((e) => e.type === "explore-complete"));
|
|
189
|
+
});
|
|
190
|
+
let calls = [];
|
|
191
|
+
const originalFetch = globalThis.fetch;
|
|
192
|
+
function mockFetch(handler) {
|
|
193
|
+
globalThis.fetch = (async (url, init) => {
|
|
194
|
+
const u = typeof url === "string" ? url : url.toString();
|
|
195
|
+
calls.push({ url: u, init: init ?? {} });
|
|
196
|
+
return handler(u, init ?? {});
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
(0, node_test_1.afterEach)(() => {
|
|
200
|
+
globalThis.fetch = originalFetch;
|
|
201
|
+
calls = [];
|
|
202
|
+
});
|
|
203
|
+
(0, node_test_1.test)("claimNextExploration returns null on 204", async () => {
|
|
204
|
+
mockFetch(() => new Response(null, { status: 204 }));
|
|
205
|
+
const s = await api.claimNextExploration(cfg);
|
|
206
|
+
strict_1.default.equal(s, null);
|
|
207
|
+
strict_1.default.equal(calls[0].url, "http://127.0.0.1:8787/agent/explore/next");
|
|
208
|
+
strict_1.default.equal(calls[0].init.method, "POST");
|
|
209
|
+
strict_1.default.equal(calls[0].init.headers.Authorization, "Bearer test-token");
|
|
210
|
+
});
|
|
211
|
+
(0, node_test_1.test)("claimNextExploration returns the parsed session on 200", async () => {
|
|
212
|
+
const payload = makeSession();
|
|
213
|
+
mockFetch(() => Response.json(payload));
|
|
214
|
+
const s = await api.claimNextExploration(cfg);
|
|
215
|
+
strict_1.default.deepEqual(s, payload);
|
|
216
|
+
});
|
|
217
|
+
(0, node_test_1.test)("postExploreDecide starts a job then polls decide/{jobId} for the result", async () => {
|
|
218
|
+
mockFetch((url, init) => {
|
|
219
|
+
if (init.method === "POST")
|
|
220
|
+
return Response.json({ jobId: "d-1", status: "running" });
|
|
221
|
+
return Response.json({ status: "done", result: { action: "click", args: { testId: "x" }, reasoning: "r" } });
|
|
222
|
+
});
|
|
223
|
+
const out = await api.postExploreDecide(cfg, "S1", {
|
|
224
|
+
observation: { accessibility: {}, elements: [], url: "https://app/x", path: "/x" },
|
|
225
|
+
history: [],
|
|
226
|
+
stepsTaken: 0,
|
|
227
|
+
});
|
|
228
|
+
strict_1.default.equal(calls[0].url, "http://127.0.0.1:8787/agent/explore/S1/decide");
|
|
229
|
+
strict_1.default.equal(calls[0].init.method, "POST");
|
|
230
|
+
strict_1.default.equal(calls[1].url, "http://127.0.0.1:8787/agent/explore/S1/decide/d-1");
|
|
231
|
+
strict_1.default.equal(calls[1].init.method, "GET");
|
|
232
|
+
strict_1.default.equal(out.action, "click");
|
|
233
|
+
});
|
|
234
|
+
(0, node_test_1.test)("postExploreDecide surfaces a server-side error job", async () => {
|
|
235
|
+
mockFetch((url, init) => {
|
|
236
|
+
if (init.method === "POST")
|
|
237
|
+
return Response.json({ jobId: "d-2", status: "running" });
|
|
238
|
+
return Response.json({ status: "error", error: "budget exhausted before start" });
|
|
239
|
+
});
|
|
240
|
+
await strict_1.default.rejects(() => api.postExploreDecide(cfg, "S1", {
|
|
241
|
+
observation: { accessibility: {}, elements: [], url: "u", path: "/" },
|
|
242
|
+
history: [],
|
|
243
|
+
stepsTaken: 0,
|
|
244
|
+
}), /budget exhausted/);
|
|
245
|
+
});
|
|
246
|
+
(0, node_test_1.test)("postExploreEvent wraps event+payload", async () => {
|
|
247
|
+
mockFetch(() => new Response(null, { status: 200 }));
|
|
248
|
+
await api.postExploreEvent(cfg, "S1", "explore.progress", { step: 1 });
|
|
249
|
+
strict_1.default.equal(calls[0].url, "http://127.0.0.1:8787/agent/explore/S1/events");
|
|
250
|
+
const body = JSON.parse(calls[0].init.body);
|
|
251
|
+
strict_1.default.deepEqual(body, { event: "explore.progress", payload: { step: 1 } });
|
|
252
|
+
});
|
|
253
|
+
(0, node_test_1.test)("postExploreFinalize posts the accumulated discovery", async () => {
|
|
254
|
+
mockFetch(() => new Response(null, { status: 200 }));
|
|
255
|
+
await api.postExploreFinalize(cfg, "S1", {
|
|
256
|
+
discovered: { routes: ["/x"], selectors: [] },
|
|
257
|
+
log: [],
|
|
258
|
+
stopReason: "done",
|
|
259
|
+
stepsTaken: 2,
|
|
260
|
+
});
|
|
261
|
+
strict_1.default.equal(calls[0].url, "http://127.0.0.1:8787/agent/explore/S1/finalize");
|
|
262
|
+
const body = JSON.parse(calls[0].init.body);
|
|
263
|
+
strict_1.default.equal(body.stopReason, "done");
|
|
264
|
+
strict_1.default.equal(body.stepsTaken, 2);
|
|
265
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@q-agent/agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
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": {
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Long-lived Playwright driver for the DOM Exploration Agent (ADR 0010 §1).
|
|
2
|
+
//
|
|
3
|
+
// Vendored verbatim from api/app/services/pw_scripts/explore_session.cjs so the
|
|
4
|
+
// Local Agent drives the SAME observe/act/close protocol the server built. Like
|
|
5
|
+
// vendor/capture_auth.cjs, it `require('playwright')` — resolved against the
|
|
6
|
+
// agent's bundled @playwright/test via NODE_PATH (set by the spawning code in
|
|
7
|
+
// runner.ts), so no CDN/global Playwright is needed.
|
|
8
|
+
//
|
|
9
|
+
// Unlike the rest of the app — which drives Playwright as one-shot
|
|
10
|
+
// `npx playwright test` subprocesses — the exploration loop is interactive: the
|
|
11
|
+
// agent must OBSERVE page state after each action to DECIDE the next one. So
|
|
12
|
+
// this process launches ONE chromium browser + page and keeps it open for the
|
|
13
|
+
// whole session, reading newline-delimited JSON commands on stdin and writing
|
|
14
|
+
// newline-delimited JSON responses on stdout.
|
|
15
|
+
//
|
|
16
|
+
// Args: baseURL, [storageState] (storageState = absolute path to a saved
|
|
17
|
+
// Playwright session for auth reuse, per ADR 0002).
|
|
18
|
+
//
|
|
19
|
+
// Protocol (one JSON object per line, each way):
|
|
20
|
+
// {"cmd":"observe"}
|
|
21
|
+
// -> {"ok":true,"url":..,"path":..,"a11y":<ariaSnapshot() role+name tree>,
|
|
22
|
+
// "elements":[<distilled interactive DOM>]}
|
|
23
|
+
// {"cmd":"act","action":"goto|click|fill|expectVisible","args":{...}}
|
|
24
|
+
// -> {"ok":bool,"error":str|null,"changed":bool}
|
|
25
|
+
// {"cmd":"close"} -> process exits.
|
|
26
|
+
//
|
|
27
|
+
// Every command is guarded so a bad action returns {ok:false,error} and never
|
|
28
|
+
// crashes the process (the session must survive a mis-step, mirroring the
|
|
29
|
+
// self-heal loop's tolerance for stale actions).
|
|
30
|
+
const { chromium } = require('playwright');
|
|
31
|
+
const readline = require('readline');
|
|
32
|
+
|
|
33
|
+
const [, , baseURL, storageState] = process.argv;
|
|
34
|
+
|
|
35
|
+
process.on('unhandledRejection', (e) => console.error('explore unhandledRejection:', e && (e.message || e)));
|
|
36
|
+
process.on('uncaughtException', (e) => console.error('explore uncaughtException:', e && (e.message || e)));
|
|
37
|
+
|
|
38
|
+
// Same selector set + shape as the self-heal distilled DOM (playwright_runner
|
|
39
|
+
// `_fixtures_ts`), so observations ground on identical real identifiers.
|
|
40
|
+
const DISTILL = () => {
|
|
41
|
+
const SEL = 'a,button,input,select,textarea,[role],[data-testid],[data-test],[id]';
|
|
42
|
+
return Array.from(document.querySelectorAll(SEL)).slice(0, 400).map((node) => {
|
|
43
|
+
const el = node;
|
|
44
|
+
const text = (el.innerText || '').trim().slice(0, 80);
|
|
45
|
+
return {
|
|
46
|
+
tag: el.tagName.toLowerCase(),
|
|
47
|
+
role: el.getAttribute('role') || undefined,
|
|
48
|
+
testId: el.getAttribute('data-testid') || el.getAttribute('data-test') || undefined,
|
|
49
|
+
id: el.id || undefined,
|
|
50
|
+
name: el.getAttribute('name') || undefined,
|
|
51
|
+
text: text || undefined,
|
|
52
|
+
placeholder: el.getAttribute('placeholder') || undefined,
|
|
53
|
+
type: el.getAttribute('type') || undefined,
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function send(obj) {
|
|
59
|
+
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let browser = null;
|
|
63
|
+
let page = null;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve a Playwright locator from the action args. Prefers stable strategies:
|
|
67
|
+
* data-testid -> role -> raw selector (CSS/testid). Returns null when the args
|
|
68
|
+
* carry neither a role nor a selector.
|
|
69
|
+
*/
|
|
70
|
+
function locatorFor(args) {
|
|
71
|
+
if (!args) return null;
|
|
72
|
+
if (args.testId) return page.getByTestId(args.testId);
|
|
73
|
+
if (args.role) return page.getByRole(args.role, args.name != null ? { name: args.name } : undefined);
|
|
74
|
+
if (args.selector) return page.locator(args.selector);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A cheap page signature (url + distilled interactive DOM) used to detect change. */
|
|
79
|
+
async function signature() {
|
|
80
|
+
try {
|
|
81
|
+
const elements = await page.evaluate(DISTILL);
|
|
82
|
+
return page.url() + '::' + JSON.stringify(elements);
|
|
83
|
+
} catch {
|
|
84
|
+
return page.url() + '::';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function doAct(action, args) {
|
|
89
|
+
args = args || {};
|
|
90
|
+
const before = await signature();
|
|
91
|
+
switch (action) {
|
|
92
|
+
case 'goto': {
|
|
93
|
+
await page.goto(args.url, { waitUntil: 'domcontentloaded' });
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
case 'click': {
|
|
97
|
+
const loc = locatorFor(args);
|
|
98
|
+
if (!loc) return { ok: false, error: 'click needs role+name or selector', changed: false };
|
|
99
|
+
await loc.first().click();
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
case 'fill': {
|
|
103
|
+
const loc = locatorFor(args);
|
|
104
|
+
if (!loc) return { ok: false, error: 'fill needs role+name or selector', changed: false };
|
|
105
|
+
await loc.first().fill(args.value != null ? String(args.value) : '');
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
case 'expectVisible': {
|
|
109
|
+
// Probe only — report visibility, never throw on absence.
|
|
110
|
+
const loc = locatorFor(args);
|
|
111
|
+
if (!loc) return { ok: false, error: 'expectVisible needs role+name or selector', changed: false };
|
|
112
|
+
let visible = false;
|
|
113
|
+
try { visible = await loc.first().isVisible(); } catch { visible = false; }
|
|
114
|
+
return { ok: visible, error: visible ? null : 'not visible', changed: false };
|
|
115
|
+
}
|
|
116
|
+
default:
|
|
117
|
+
return { ok: false, error: `unknown action: ${action}`, changed: false };
|
|
118
|
+
}
|
|
119
|
+
const after = await signature();
|
|
120
|
+
return { ok: true, error: null, changed: after !== before };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function doObserve() {
|
|
124
|
+
// Playwright removed page.accessibility; the supported role+name tree is
|
|
125
|
+
// locator.ariaSnapshot() (a compact YAML string, ideal for the model and
|
|
126
|
+
// maps directly to getByRole). Best-effort — never fail observe on it.
|
|
127
|
+
let a11y = '';
|
|
128
|
+
try { a11y = await page.locator('body').ariaSnapshot(); } catch { a11y = ''; }
|
|
129
|
+
const elements = await page.evaluate(DISTILL);
|
|
130
|
+
const url = page.url();
|
|
131
|
+
let path = '';
|
|
132
|
+
try { path = new URL(url).pathname; } catch { path = ''; }
|
|
133
|
+
return { ok: true, url, path, a11y, elements };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function handle(line) {
|
|
137
|
+
let msg;
|
|
138
|
+
try {
|
|
139
|
+
msg = JSON.parse(line);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
send({ ok: false, error: 'invalid JSON command' });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
if (msg.cmd === 'observe') {
|
|
146
|
+
send(await doObserve());
|
|
147
|
+
} else if (msg.cmd === 'act') {
|
|
148
|
+
send(await doAct(msg.action, msg.args));
|
|
149
|
+
} else if (msg.cmd === 'close') {
|
|
150
|
+
try { await browser.close(); } catch {}
|
|
151
|
+
process.exit(0);
|
|
152
|
+
} else {
|
|
153
|
+
send({ ok: false, error: `unknown cmd: ${msg.cmd}` });
|
|
154
|
+
}
|
|
155
|
+
} catch (e) {
|
|
156
|
+
send({ ok: false, error: (e && (e.message || String(e))) || 'command failed' });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
(async () => {
|
|
161
|
+
if (!baseURL) { console.error('explore_session: missing baseURL arg'); process.exit(1); }
|
|
162
|
+
browser = await chromium.launch({ headless: true });
|
|
163
|
+
const contextOpts = { baseURL };
|
|
164
|
+
if (storageState) contextOpts.storageState = storageState;
|
|
165
|
+
const context = await browser.newContext(contextOpts);
|
|
166
|
+
page = await context.newPage();
|
|
167
|
+
|
|
168
|
+
// Land on the app before the first observe so step 1 sees the real page,
|
|
169
|
+
// not about:blank. Best-effort — the model can still goto elsewhere.
|
|
170
|
+
if (baseURL) {
|
|
171
|
+
try { await page.goto(baseURL, { waitUntil: 'domcontentloaded' }); } catch {}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Serialize commands: one line in -> one response out, in order.
|
|
175
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
176
|
+
let chain = Promise.resolve();
|
|
177
|
+
rl.on('line', (line) => {
|
|
178
|
+
if (!line.trim()) return;
|
|
179
|
+
chain = chain.then(() => handle(line));
|
|
180
|
+
});
|
|
181
|
+
rl.on('close', async () => {
|
|
182
|
+
try { await browser.close(); } catch {}
|
|
183
|
+
process.exit(0);
|
|
184
|
+
});
|
|
185
|
+
// Signal readiness so the agent side knows the browser+page are up.
|
|
186
|
+
send({ ok: true, ready: true });
|
|
187
|
+
})().catch((e) => {
|
|
188
|
+
console.error('explore_session fatal:', e && (e.stack || e.message || e));
|
|
189
|
+
process.exit(1);
|
|
190
|
+
});
|