cairn-engine 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 +36 -0
- package/dist/adapters/context/inline.d.ts +8 -0
- package/dist/adapters/context/inline.js +10 -0
- package/dist/adapters/context/inline.js.map +1 -0
- package/dist/adapters/critics/assertion.d.ts +8 -0
- package/dist/adapters/critics/assertion.js +43 -0
- package/dist/adapters/critics/assertion.js.map +1 -0
- package/dist/adapters/critics/llm.d.ts +10 -0
- package/dist/adapters/critics/llm.js +68 -0
- package/dist/adapters/critics/llm.js.map +1 -0
- package/dist/adapters/drivers/chrome.d.ts +37 -0
- package/dist/adapters/drivers/chrome.js +195 -0
- package/dist/adapters/drivers/chrome.js.map +1 -0
- package/dist/adapters/drivers/fake.d.ts +24 -0
- package/dist/adapters/drivers/fake.js +37 -0
- package/dist/adapters/drivers/fake.js.map +1 -0
- package/dist/adapters/drivers/self-heal.d.ts +34 -0
- package/dist/adapters/drivers/self-heal.js +89 -0
- package/dist/adapters/drivers/self-heal.js.map +1 -0
- package/dist/adapters/llm/anthropic.d.ts +18 -0
- package/dist/adapters/llm/anthropic.js +41 -0
- package/dist/adapters/llm/anthropic.js.map +1 -0
- package/dist/adapters/llm/claude-code.d.ts +14 -0
- package/dist/adapters/llm/claude-code.js +48 -0
- package/dist/adapters/llm/claude-code.js.map +1 -0
- package/dist/adapters/llm/factory.d.ts +8 -0
- package/dist/adapters/llm/factory.js +9 -0
- package/dist/adapters/llm/factory.js.map +1 -0
- package/dist/adapters/planners/static.d.ts +8 -0
- package/dist/adapters/planners/static.js +10 -0
- package/dist/adapters/planners/static.js.map +1 -0
- package/dist/adapters/reporters/console.d.ts +6 -0
- package/dist/adapters/reporters/console.js +17 -0
- package/dist/adapters/reporters/console.js.map +1 -0
- package/dist/adapters/reporters/json.d.ts +7 -0
- package/dist/adapters/reporters/json.js +12 -0
- package/dist/adapters/reporters/json.js.map +1 -0
- package/dist/adapters/skills/file-store.d.ts +11 -0
- package/dist/adapters/skills/file-store.js +43 -0
- package/dist/adapters/skills/file-store.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +158 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/discover.d.ts +25 -0
- package/dist/core/discover.js +93 -0
- package/dist/core/discover.js.map +1 -0
- package/dist/core/pipeline.d.ts +8 -0
- package/dist/core/pipeline.js +49 -0
- package/dist/core/pipeline.js.map +1 -0
- package/dist/core/ports.d.ts +56 -0
- package/dist/core/ports.js +2 -0
- package/dist/core/ports.js.map +1 -0
- package/dist/core/types.d.ts +102 -0
- package/dist/core/types.js +3 -0
- package/dist/core/types.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/run.d.ts +25 -0
- package/dist/run.js +53 -0
- package/dist/run.js.map +1 -0
- package/package.json +57 -0
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# cairn-engine
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
The engine behind [cairn](https://github.com/team-poem/cairn) — browser tests an AI
|
|
6
|
+
discovers once, replays deterministically (no LLM in the loop), and self-heals when the UI
|
|
7
|
+
drifts. Model- and browser-agnostic; embed it or drive it from the `cairn` CLI.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install cairn-engine
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
# discover an LLM walks the app once and writes a scenario
|
|
15
|
+
cairn discover "follow the link to learn more" --url https://example.com --freeze t.json
|
|
16
|
+
# replay deterministic, no LLM; non-zero exit on failure (CI gate)
|
|
17
|
+
cairn replay t.json
|
|
18
|
+
# heal repair a broken step via the LLM and re-freeze
|
|
19
|
+
cairn replay t.json --heal --freeze t.json
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Embed it — every stage is an injected port:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { runScenario } from "cairn-engine";
|
|
26
|
+
|
|
27
|
+
const { result } = await runScenario(scenario, { heal: true });
|
|
28
|
+
if (!result.verdict.passed) process.exit(1);
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
No API key needed if you have **Claude Code** installed (cairn shells out to it); set
|
|
32
|
+
`ANTHROPIC_API_KEY` to use the Anthropic API instead.
|
|
33
|
+
|
|
34
|
+
**Full docs, design, and the loop diagram:** https://github.com/team-poem/cairn
|
|
35
|
+
|
|
36
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Simplest ContextProvider: the task string is the intent. */
|
|
2
|
+
import type { ContextProvider } from "../../core/ports.js";
|
|
3
|
+
import type { Context } from "../../core/types.js";
|
|
4
|
+
export declare class InlineContextProvider implements ContextProvider {
|
|
5
|
+
private readonly baseUrl?;
|
|
6
|
+
constructor(baseUrl?: string | undefined);
|
|
7
|
+
provide(task: string): Promise<Context>;
|
|
8
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"inline.js","sourceRoot":"","sources":["../../../src/adapters/context/inline.ts"],"names":[],"mappings":"AAIA,MAAM,OAAO,qBAAqB;IACH;IAA7B,YAA6B,OAAgB;QAAhB,YAAO,GAAP,OAAO,CAAS;IAAG,CAAC;IAEjD,KAAK,CAAC,OAAO,CAAC,IAAY;QACxB,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC;IACjD,CAAC;CACF"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Deterministic Critic for the replay path — checks assertions against evidence, no LLM (invariant #4). */
|
|
2
|
+
import type { Critic } from "../../core/ports.js";
|
|
3
|
+
import type { Assertion, AssertionResult, Evidence, Verdict } from "../../core/types.js";
|
|
4
|
+
/** Evaluate one mechanical assertion. `expect` is not mechanical — returns unsupported (LlmCritic handles it). */
|
|
5
|
+
export declare function checkAssertion(assertion: Assertion, evidence: Evidence): AssertionResult;
|
|
6
|
+
export declare class AssertionCritic implements Critic {
|
|
7
|
+
judge(evidence: Evidence, assertions: Assertion[]): Promise<Verdict>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Evaluate one mechanical assertion. `expect` is not mechanical — returns unsupported (LlmCritic handles it). */
|
|
2
|
+
export function checkAssertion(assertion, evidence) {
|
|
3
|
+
switch (assertion.kind) {
|
|
4
|
+
case "navigated": {
|
|
5
|
+
const { navigated, finalUrl } = evidence.execution;
|
|
6
|
+
if (!navigated)
|
|
7
|
+
return { assertion, passed: false, detail: "no navigation occurred" };
|
|
8
|
+
if (assertion.to && !(finalUrl ?? "").includes(assertion.to)) {
|
|
9
|
+
return { assertion, passed: false, detail: `final url ${finalUrl} does not include ${assertion.to}` };
|
|
10
|
+
}
|
|
11
|
+
return { assertion, passed: true, detail: finalUrl };
|
|
12
|
+
}
|
|
13
|
+
case "no-console-errors": {
|
|
14
|
+
const errors = evidence.logic.console.filter((m) => m.type === "error");
|
|
15
|
+
return errors.length === 0
|
|
16
|
+
? { assertion, passed: true }
|
|
17
|
+
: { assertion, passed: false, detail: `${errors.length} console error(s): ${errors[0]?.text}` };
|
|
18
|
+
}
|
|
19
|
+
case "no-failed-requests": {
|
|
20
|
+
const failed = evidence.logic.requests.filter((r) => r.status >= 400);
|
|
21
|
+
return failed.length === 0
|
|
22
|
+
? { assertion, passed: true }
|
|
23
|
+
: { assertion, passed: false, detail: `${failed.length} failed request(s): ${failed[0]?.status} ${failed[0]?.url}` };
|
|
24
|
+
}
|
|
25
|
+
case "request-status": {
|
|
26
|
+
const match = evidence.logic.requests.find((r) => r.url.includes(assertion.urlIncludes));
|
|
27
|
+
if (!match)
|
|
28
|
+
return { assertion, passed: false, detail: `no request matching ${assertion.urlIncludes}` };
|
|
29
|
+
return match.status === assertion.status
|
|
30
|
+
? { assertion, passed: true, detail: `${match.status} ${match.url}` }
|
|
31
|
+
: { assertion, passed: false, detail: `expected ${assertion.status}, got ${match.status} for ${match.url}` };
|
|
32
|
+
}
|
|
33
|
+
case "expect":
|
|
34
|
+
return { assertion, passed: false, detail: "'expect' is judged by LlmCritic, not the deterministic critic" };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export class AssertionCritic {
|
|
38
|
+
async judge(evidence, assertions) {
|
|
39
|
+
const results = assertions.map((a) => checkAssertion(a, evidence));
|
|
40
|
+
return { passed: results.every((r) => r.passed), results };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=assertion.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"assertion.js","sourceRoot":"","sources":["../../../src/adapters/critics/assertion.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Critic, LlmClient } from "../../core/ports.js";
|
|
2
|
+
import type { Assertion, Evidence, Verdict } from "../../core/types.js";
|
|
3
|
+
/** Compact, judge-friendly rendering of the three evidence layers. */
|
|
4
|
+
export declare function summarizeEvidence(evidence: Evidence): string;
|
|
5
|
+
export declare class LlmCritic implements Critic {
|
|
6
|
+
private readonly llm;
|
|
7
|
+
constructor(llm: LlmClient);
|
|
8
|
+
private judgeExpect;
|
|
9
|
+
judge(evidence: Evidence, assertions: Assertion[]): Promise<Verdict>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Critic that judges natural-language `expect` criteria with an LLM and delegates mechanical
|
|
3
|
+
* assertions to the deterministic checker. The LLM runs ONLY for `expect`, so a scenario with
|
|
4
|
+
* none makes zero LLM calls and stays deterministic (invariant #4). Judgment is grounded in the
|
|
5
|
+
* three-layer evidence (design §6), behind the LlmClient seam (invariant #5).
|
|
6
|
+
*/
|
|
7
|
+
import { checkAssertion } from "./assertion.js";
|
|
8
|
+
const SYSTEM = "You are a QA critic. Given observed evidence from a browser run and a success " +
|
|
9
|
+
"criterion, decide whether the criterion is satisfied. Judge only from the evidence; " +
|
|
10
|
+
'do not assume. Respond with strict JSON, no prose, no code fences: {"passed":true|false,"detail":"<short reason>"}.';
|
|
11
|
+
/** Compact, judge-friendly rendering of the three evidence layers. */
|
|
12
|
+
export function summarizeEvidence(evidence) {
|
|
13
|
+
const { execution, logic } = evidence;
|
|
14
|
+
const requests = logic.requests
|
|
15
|
+
.slice(0, 40)
|
|
16
|
+
.map((r) => `${r.status} ${r.method} ${r.url}`)
|
|
17
|
+
.join("\n");
|
|
18
|
+
const errors = logic.console.filter((m) => m.type === "error").map((m) => m.text);
|
|
19
|
+
return [
|
|
20
|
+
`navigated: ${execution.navigated}`,
|
|
21
|
+
`finalUrl: ${execution.finalUrl ?? "(none)"}`,
|
|
22
|
+
`blocked: ${execution.blocked}`,
|
|
23
|
+
`requests (${logic.requests.length}):`,
|
|
24
|
+
requests || "(none)",
|
|
25
|
+
`console errors (${errors.length}):`,
|
|
26
|
+
errors.join("\n") || "(none)",
|
|
27
|
+
].join("\n");
|
|
28
|
+
}
|
|
29
|
+
function parseVerdict(text) {
|
|
30
|
+
let s = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "");
|
|
31
|
+
const start = s.indexOf("{");
|
|
32
|
+
const end = s.lastIndexOf("}");
|
|
33
|
+
if (start === -1 || end === -1)
|
|
34
|
+
throw new Error(`no JSON in critic reply: ${text.slice(0, 200)}`);
|
|
35
|
+
const obj = JSON.parse(s.slice(start, end + 1));
|
|
36
|
+
return { passed: obj.passed === true, detail: typeof obj.detail === "string" ? obj.detail : undefined };
|
|
37
|
+
}
|
|
38
|
+
export class LlmCritic {
|
|
39
|
+
llm;
|
|
40
|
+
constructor(llm) {
|
|
41
|
+
this.llm = llm;
|
|
42
|
+
}
|
|
43
|
+
async judgeExpect(criterion, evidence, assertion) {
|
|
44
|
+
const prompt = [
|
|
45
|
+
`Success criterion: ${criterion}`,
|
|
46
|
+
``,
|
|
47
|
+
`Evidence:`,
|
|
48
|
+
summarizeEvidence(evidence),
|
|
49
|
+
``,
|
|
50
|
+
`Is the criterion satisfied? Respond with JSON only.`,
|
|
51
|
+
].join("\n");
|
|
52
|
+
try {
|
|
53
|
+
const reply = await this.llm.complete(prompt, { system: SYSTEM });
|
|
54
|
+
const v = parseVerdict(reply);
|
|
55
|
+
return { assertion, passed: v.passed, detail: v.detail ?? `judged by ${this.llm.id}` };
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
return { assertion, passed: false, detail: `LLM judgment failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async judge(evidence, assertions) {
|
|
62
|
+
const results = await Promise.all(assertions.map((a) => a.kind === "expect"
|
|
63
|
+
? this.judgeExpect(a.criterion, evidence, a)
|
|
64
|
+
: Promise.resolve(checkAssertion(a, evidence))));
|
|
65
|
+
return { passed: results.every((r) => r.passed), results };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=llm.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"llm.js","sourceRoot":"","sources":["../../../src/adapters/critics/llm.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Driver } from "../../core/ports.js";
|
|
2
|
+
import type { ConsoleMessage, Evidence, NetworkRequest, PageElement, SettleOptions, Target } from "../../core/types.js";
|
|
3
|
+
export interface ChromeDriverOptions {
|
|
4
|
+
command?: string;
|
|
5
|
+
args?: string[];
|
|
6
|
+
}
|
|
7
|
+
export declare class ChromeDevToolsDriver implements Driver {
|
|
8
|
+
private readonly opts;
|
|
9
|
+
private client?;
|
|
10
|
+
private transport?;
|
|
11
|
+
private initialUrl?;
|
|
12
|
+
constructor(opts?: ChromeDriverOptions);
|
|
13
|
+
private ensureConnected;
|
|
14
|
+
private call;
|
|
15
|
+
goto(url: string): Promise<void>;
|
|
16
|
+
click(target: Target): Promise<void>;
|
|
17
|
+
type(target: Target, text: string): Promise<void>;
|
|
18
|
+
snapshot(): Promise<PageElement[]>;
|
|
19
|
+
settle(options?: SettleOptions): Promise<void>;
|
|
20
|
+
observe(): Promise<Evidence>;
|
|
21
|
+
close(): Promise<void>;
|
|
22
|
+
private resolveUid;
|
|
23
|
+
}
|
|
24
|
+
/** `uid=1_3 link "Learn more" …` → {role:"link", name:"Learn more"} for named rows. */
|
|
25
|
+
export declare function parseElements(snapshot: string): PageElement[];
|
|
26
|
+
/** `uid=1_3 link "Learn more" …` → first uid whose quoted name includes `text`. */
|
|
27
|
+
export declare function findUidByName(snapshot: string, text: string): string | undefined;
|
|
28
|
+
/** `reqid=5 GET https://… [200]` → NetworkRequest[]. */
|
|
29
|
+
export declare function parseNetwork(text: string): NetworkRequest[];
|
|
30
|
+
/** Console listing → messages. Conservative: only rows naming a known type. */
|
|
31
|
+
export declare function parseConsole(text: string): ConsoleMessage[];
|
|
32
|
+
/** Canonicalize a url for comparison: drop a trailing slash and the hash. */
|
|
33
|
+
export declare function normalizeUrl(u: string): string;
|
|
34
|
+
/** True only if the page genuinely moved — not just a trailing-slash difference. */
|
|
35
|
+
export declare function isNavigation(initialUrl: string | undefined, finalUrl: string): boolean;
|
|
36
|
+
/** `2: Example Domain (https://example.com/) [selected]` → the selected page's url. */
|
|
37
|
+
export declare function parseSelectedUrl(text: string): string | undefined;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default Driver — drives a real browser via the Chrome DevTools MCP server, which this
|
|
3
|
+
* embeds as a client and spawns over stdio (so `cairn run` is self-contained). Everything
|
|
4
|
+
* Chrome-specific, including parsing the MCP's human-readable text, stays here behind the
|
|
5
|
+
* Driver port (invariant #5).
|
|
6
|
+
*/
|
|
7
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
8
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
9
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
10
|
+
const MCP_COMMAND = "npx";
|
|
11
|
+
// `--isolated` gives the harness its own ephemeral browser, so a standalone `cairn run`
|
|
12
|
+
// never collides with another chrome-devtools-mcp using the default profile.
|
|
13
|
+
const MCP_ARGS = ["-y", "chrome-devtools-mcp@latest", "--isolated"];
|
|
14
|
+
export class ChromeDevToolsDriver {
|
|
15
|
+
opts;
|
|
16
|
+
client;
|
|
17
|
+
transport;
|
|
18
|
+
initialUrl;
|
|
19
|
+
constructor(opts = {}) {
|
|
20
|
+
this.opts = opts;
|
|
21
|
+
}
|
|
22
|
+
async ensureConnected() {
|
|
23
|
+
if (this.client)
|
|
24
|
+
return this.client;
|
|
25
|
+
const client = new Client({ name: "cairn-harness", version: "0.0.0" }, { capabilities: {} });
|
|
26
|
+
const transport = new StdioClientTransport({
|
|
27
|
+
command: this.opts.command ?? MCP_COMMAND,
|
|
28
|
+
args: this.opts.args ?? MCP_ARGS,
|
|
29
|
+
});
|
|
30
|
+
await client.connect(transport);
|
|
31
|
+
this.client = client;
|
|
32
|
+
this.transport = transport;
|
|
33
|
+
return client;
|
|
34
|
+
}
|
|
35
|
+
async call(name, args = {}) {
|
|
36
|
+
const client = await this.ensureConnected();
|
|
37
|
+
const res = (await client.callTool({ name, arguments: args }));
|
|
38
|
+
const text = (res.content ?? [])
|
|
39
|
+
.filter((c) => c.type === "text" && typeof c.text === "string")
|
|
40
|
+
.map((c) => c.text)
|
|
41
|
+
.join("\n");
|
|
42
|
+
if (res.isError)
|
|
43
|
+
throw new Error(`MCP ${name} failed: ${text}`);
|
|
44
|
+
return text;
|
|
45
|
+
}
|
|
46
|
+
async goto(url) {
|
|
47
|
+
if (this.initialUrl === undefined)
|
|
48
|
+
this.initialUrl = url;
|
|
49
|
+
await this.call("navigate_page", { type: "url", url });
|
|
50
|
+
}
|
|
51
|
+
async click(target) {
|
|
52
|
+
const uid = await this.resolveUid(target);
|
|
53
|
+
await this.call("click", { uid });
|
|
54
|
+
}
|
|
55
|
+
async type(target, text) {
|
|
56
|
+
const uid = await this.resolveUid(target);
|
|
57
|
+
await this.call("fill", { uid, value: text });
|
|
58
|
+
}
|
|
59
|
+
async snapshot() {
|
|
60
|
+
return parseElements(await this.call("take_snapshot"));
|
|
61
|
+
}
|
|
62
|
+
async settle(options = {}) {
|
|
63
|
+
// Chrome defers low-priority resources (favicon, web fonts) past the usual 500ms
|
|
64
|
+
// "network-idle" window, so the idle threshold is generous — missing a late request
|
|
65
|
+
// would mean missing a real failure. Tune via SettleOptions.
|
|
66
|
+
const idleMs = options.idleMs ?? 1_000;
|
|
67
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
68
|
+
const pollMs = options.pollMs ?? 250;
|
|
69
|
+
const deadline = Date.now() + timeoutMs;
|
|
70
|
+
let lastCount = -1;
|
|
71
|
+
let stableSince = Date.now();
|
|
72
|
+
try {
|
|
73
|
+
while (Date.now() < deadline) {
|
|
74
|
+
const count = parseNetwork(await this.call("list_network_requests")).length;
|
|
75
|
+
if (count !== lastCount) {
|
|
76
|
+
lastCount = count;
|
|
77
|
+
stableSince = Date.now();
|
|
78
|
+
}
|
|
79
|
+
else if (Date.now() - stableSince >= idleMs) {
|
|
80
|
+
return; // count held steady long enough — treat as network-idle
|
|
81
|
+
}
|
|
82
|
+
await delay(pollMs);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// best-effort: settling must never fail a run (port contract).
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async observe() {
|
|
90
|
+
const [pages, network, console] = await Promise.all([
|
|
91
|
+
this.call("list_pages"),
|
|
92
|
+
this.call("list_network_requests"),
|
|
93
|
+
this.call("list_console_messages"),
|
|
94
|
+
]);
|
|
95
|
+
const finalUrl = parseSelectedUrl(pages);
|
|
96
|
+
const navigated = finalUrl !== undefined && isNavigation(this.initialUrl, finalUrl);
|
|
97
|
+
return {
|
|
98
|
+
execution: { actions: [], navigated, finalUrl, blocked: false },
|
|
99
|
+
perception: {},
|
|
100
|
+
logic: { requests: parseNetwork(network), console: parseConsole(console) },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
async close() {
|
|
104
|
+
await this.client?.close().catch(() => { });
|
|
105
|
+
this.client = undefined;
|
|
106
|
+
this.transport = undefined;
|
|
107
|
+
}
|
|
108
|
+
async resolveUid(target) {
|
|
109
|
+
if (target.selector) {
|
|
110
|
+
throw new Error("ChromeDevToolsDriver resolves targets by text, not CSS selector");
|
|
111
|
+
}
|
|
112
|
+
if (!target.text)
|
|
113
|
+
throw new Error("target needs a `text` to resolve an element");
|
|
114
|
+
const uid = findUidByName(await this.call("take_snapshot"), target.text);
|
|
115
|
+
if (!uid)
|
|
116
|
+
throw new Error(`no element with accessible name matching "${target.text}"`);
|
|
117
|
+
return uid;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// --- parsers for chrome-devtools-mcp's text output -------------------------------
|
|
121
|
+
/** `uid=1_3 link "Learn more" …` → {role:"link", name:"Learn more"} for named rows. */
|
|
122
|
+
export function parseElements(snapshot) {
|
|
123
|
+
const out = [];
|
|
124
|
+
for (const line of snapshot.split("\n")) {
|
|
125
|
+
const m = line.match(/uid=\S+\s+(\w+)\s+"([^"]*)"/);
|
|
126
|
+
if (m && m[2].trim())
|
|
127
|
+
out.push({ role: m[1], name: m[2] });
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
/** `uid=1_3 link "Learn more" …` → first uid whose quoted name includes `text`. */
|
|
132
|
+
export function findUidByName(snapshot, text) {
|
|
133
|
+
const needle = text.toLowerCase();
|
|
134
|
+
for (const line of snapshot.split("\n")) {
|
|
135
|
+
const uidMatch = line.match(/uid=(\S+)/);
|
|
136
|
+
if (!uidMatch)
|
|
137
|
+
continue;
|
|
138
|
+
const nameMatch = line.match(/"([^"]*)"/);
|
|
139
|
+
if (nameMatch && nameMatch[1].toLowerCase().includes(needle)) {
|
|
140
|
+
return uidMatch[1];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
/** `reqid=5 GET https://… [200]` → NetworkRequest[]. */
|
|
146
|
+
export function parseNetwork(text) {
|
|
147
|
+
const out = [];
|
|
148
|
+
for (const line of text.split("\n")) {
|
|
149
|
+
const m = line.match(/^reqid=\d+\s+(\w+)\s+(\S+)\s+\[(\d+)\]/);
|
|
150
|
+
if (m)
|
|
151
|
+
out.push({ method: m[1], url: m[2], status: Number(m[3]) });
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
/** Console listing → messages. Conservative: only rows naming a known type. */
|
|
156
|
+
export function parseConsole(text) {
|
|
157
|
+
const out = [];
|
|
158
|
+
for (const line of text.split("\n")) {
|
|
159
|
+
const m = line.match(/^\s*(?:msgid=\d+\s+)?(log|debug|info|error|warn|trace|verbose)[:>\s]\s*(.*)$/i);
|
|
160
|
+
if (m)
|
|
161
|
+
out.push({ type: m[1].toLowerCase(), text: m[2].trim() });
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
/** Canonicalize a url for comparison: drop a trailing slash and the hash. */
|
|
166
|
+
export function normalizeUrl(u) {
|
|
167
|
+
try {
|
|
168
|
+
const url = new URL(u);
|
|
169
|
+
return `${url.origin}${url.pathname.replace(/\/$/, "")}${url.search}`;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return u.replace(/[/#]+$/, "");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** True only if the page genuinely moved — not just a trailing-slash difference. */
|
|
176
|
+
export function isNavigation(initialUrl, finalUrl) {
|
|
177
|
+
if (initialUrl === undefined)
|
|
178
|
+
return true;
|
|
179
|
+
return normalizeUrl(initialUrl) !== normalizeUrl(finalUrl);
|
|
180
|
+
}
|
|
181
|
+
/** `2: Example Domain (https://example.com/) [selected]` → the selected page's url. */
|
|
182
|
+
export function parseSelectedUrl(text) {
|
|
183
|
+
for (const line of text.split("\n")) {
|
|
184
|
+
if (!line.includes("[selected]"))
|
|
185
|
+
continue;
|
|
186
|
+
const paren = line.match(/\((https?:\/\/[^)]+)\)/);
|
|
187
|
+
if (paren)
|
|
188
|
+
return paren[1];
|
|
189
|
+
const bare = line.match(/:\s*(\S+)\s*\[selected\]/);
|
|
190
|
+
if (bare)
|
|
191
|
+
return bare[1];
|
|
192
|
+
}
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
//# sourceMappingURL=chrome.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chrome.js","sourceRoot":"","sources":["../../../src/adapters/drivers/chrome.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** In-memory Driver for tests (no browser); also proves the core is driver-agnostic (invariant #5). */
|
|
2
|
+
import type { Driver } from "../../core/ports.js";
|
|
3
|
+
import type { Evidence, PageElement, Target } from "../../core/types.js";
|
|
4
|
+
export interface FakeScript {
|
|
5
|
+
evidence: Evidence;
|
|
6
|
+
elements?: PageElement[];
|
|
7
|
+
/** Targets (by text) that should throw when acted on, to simulate a broken step. */
|
|
8
|
+
failOn?: string[];
|
|
9
|
+
}
|
|
10
|
+
export declare class FakeDriver implements Driver {
|
|
11
|
+
private readonly script;
|
|
12
|
+
closed: boolean;
|
|
13
|
+
settled: boolean;
|
|
14
|
+
readonly visited: string[];
|
|
15
|
+
readonly clicked: Target[];
|
|
16
|
+
constructor(script: FakeScript);
|
|
17
|
+
goto(url: string): Promise<void>;
|
|
18
|
+
click(target: Target): Promise<void>;
|
|
19
|
+
type(target: Target, _text: string): Promise<void>;
|
|
20
|
+
snapshot(): Promise<PageElement[]>;
|
|
21
|
+
settle(): Promise<void>;
|
|
22
|
+
observe(): Promise<Evidence>;
|
|
23
|
+
close(): Promise<void>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export class FakeDriver {
|
|
2
|
+
script;
|
|
3
|
+
closed = false;
|
|
4
|
+
settled = false;
|
|
5
|
+
visited = [];
|
|
6
|
+
clicked = [];
|
|
7
|
+
constructor(script) {
|
|
8
|
+
this.script = script;
|
|
9
|
+
}
|
|
10
|
+
async goto(url) {
|
|
11
|
+
this.visited.push(url);
|
|
12
|
+
}
|
|
13
|
+
async click(target) {
|
|
14
|
+
if (target.text && this.script.failOn?.includes(target.text)) {
|
|
15
|
+
throw new Error(`element not found: ${target.text}`);
|
|
16
|
+
}
|
|
17
|
+
this.clicked.push(target);
|
|
18
|
+
}
|
|
19
|
+
async type(target, _text) {
|
|
20
|
+
if (target.text && this.script.failOn?.includes(target.text)) {
|
|
21
|
+
throw new Error(`element not found: ${target.text}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async snapshot() {
|
|
25
|
+
return this.script.elements ?? [];
|
|
26
|
+
}
|
|
27
|
+
async settle() {
|
|
28
|
+
this.settled = true;
|
|
29
|
+
}
|
|
30
|
+
async observe() {
|
|
31
|
+
return this.script.evidence;
|
|
32
|
+
}
|
|
33
|
+
async close() {
|
|
34
|
+
this.closed = true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=fake.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fake.js","sourceRoot":"","sources":["../../../src/adapters/drivers/fake.ts"],"names":[],"mappings":"AAWA,MAAM,OAAO,UAAU;IAMQ;IAL7B,MAAM,GAAG,KAAK,CAAC;IACf,OAAO,GAAG,KAAK,CAAC;IACP,OAAO,GAAa,EAAE,CAAC;IACvB,OAAO,GAAa,EAAE,CAAC;IAEhC,YAA6B,MAAkB;QAAlB,WAAM,GAAN,MAAM,CAAY;IAAG,CAAC;IAEnD,KAAK,CAAC,IAAI,CAAC,GAAW;QACpB,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACzB,CAAC;IAED,KAAK,CAAC,KAAK,CAAC,MAAc;QACxB,IAAI,MAAM,CAAC,IAAI,IAAI,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC;YAC7D,MAAM,IAAI,KAAK,CAAC,sBAAsB,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;QACvD,CAAC;QACD,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAC5B,CAAC;IAED,KAAK,CAAC,IAAI,CAAC,MAAc,EAAE,KAAa;QACtC,IAAI,MAAM,CAAC,IAAI,IAAI,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC;YAC7D,MAAM,IAAI,KAAK,CAAC,sBAAsB,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;QACvD,CAAC;IACH,CAAC;IAED,KAAK,CAAC,QAAQ;QACZ,OAAO,IAAI,CAAC,MAAM,CAAC,QAAQ,IAAI,EAAE,CAAC;IACpC,CAAC;IAED,KAAK,CAAC,MAAM;QACV,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC;IACtB,CAAC;IAED,KAAK,CAAC,OAAO;QACX,OAAO,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC;IAC9B,CAAC;IAED,KAAK,CAAC,KAAK;QACT,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC;IACrB,CAAC;CACF"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Driver decorator (wraps any Driver) that repairs a broken step at replay time — the
|
|
3
|
+
* sanctioned exception to LLM-free replay (invariant #4). When a frozen target no longer
|
|
4
|
+
* resolves, the LLM maps the original intent onto a current element, the action is retried,
|
|
5
|
+
* and the substitution is recorded for re-freezing. No break → no LLM call.
|
|
6
|
+
*/
|
|
7
|
+
import type { Driver, LlmClient } from "../../core/ports.js";
|
|
8
|
+
import type { Evidence, PageElement, SettleOptions, Target } from "../../core/types.js";
|
|
9
|
+
/** A recorded substitution: `original` could not be found, `healedText` was used instead. */
|
|
10
|
+
export interface Heal {
|
|
11
|
+
original: Target;
|
|
12
|
+
healedText: string;
|
|
13
|
+
reason?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface SelfHealOptions {
|
|
16
|
+
maxHeals?: number;
|
|
17
|
+
}
|
|
18
|
+
/** Parse the heal reply → a chosen element name, or undefined for "none". */
|
|
19
|
+
export declare function parseHealChoice(text: string): string | undefined;
|
|
20
|
+
export declare class SelfHealingDriver implements Driver {
|
|
21
|
+
private readonly inner;
|
|
22
|
+
private readonly llm;
|
|
23
|
+
readonly heals: Heal[];
|
|
24
|
+
private readonly maxHeals;
|
|
25
|
+
constructor(inner: Driver, llm: LlmClient, opts?: SelfHealOptions);
|
|
26
|
+
goto(url: string): Promise<void>;
|
|
27
|
+
click(target: Target): Promise<void>;
|
|
28
|
+
type(target: Target, text: string): Promise<void>;
|
|
29
|
+
snapshot(): Promise<PageElement[]>;
|
|
30
|
+
settle(options?: SettleOptions): Promise<void>;
|
|
31
|
+
observe(): Promise<Evidence>;
|
|
32
|
+
close(): Promise<void>;
|
|
33
|
+
private heal;
|
|
34
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const HEAL_SYSTEM = "You repair a broken browser test step. A step needs to act on an element described by " +
|
|
2
|
+
"the original target, but no element with that name exists on the page now. Choose the " +
|
|
3
|
+
"CURRENT element that best fulfills the original intent, or none if nothing fits. " +
|
|
4
|
+
'Respond with strict JSON, no prose, no code fences: {"name":"<exact current element name>"} ' +
|
|
5
|
+
'or {"name":null}.';
|
|
6
|
+
function healPrompt(target, elements) {
|
|
7
|
+
const want = target.text ?? target.selector ?? "(unknown)";
|
|
8
|
+
const list = elements
|
|
9
|
+
.slice(0, 60)
|
|
10
|
+
.map((e) => `- [${e.role}] ${e.name}`)
|
|
11
|
+
.join("\n");
|
|
12
|
+
return [
|
|
13
|
+
`Original target: ${want}`,
|
|
14
|
+
``,
|
|
15
|
+
`Current interactive elements:`,
|
|
16
|
+
list || "(none)",
|
|
17
|
+
``,
|
|
18
|
+
`Which current element best matches the original target? JSON only.`,
|
|
19
|
+
].join("\n");
|
|
20
|
+
}
|
|
21
|
+
/** Parse the heal reply → a chosen element name, or undefined for "none". */
|
|
22
|
+
export function parseHealChoice(text) {
|
|
23
|
+
let s = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "");
|
|
24
|
+
const start = s.indexOf("{");
|
|
25
|
+
const end = s.lastIndexOf("}");
|
|
26
|
+
if (start === -1 || end === -1)
|
|
27
|
+
throw new Error(`no JSON in heal reply: ${text.slice(0, 200)}`);
|
|
28
|
+
const obj = JSON.parse(s.slice(start, end + 1));
|
|
29
|
+
return typeof obj.name === "string" && obj.name.trim() ? obj.name : undefined;
|
|
30
|
+
}
|
|
31
|
+
export class SelfHealingDriver {
|
|
32
|
+
inner;
|
|
33
|
+
llm;
|
|
34
|
+
heals = [];
|
|
35
|
+
maxHeals;
|
|
36
|
+
constructor(inner, llm, opts = {}) {
|
|
37
|
+
this.inner = inner;
|
|
38
|
+
this.llm = llm;
|
|
39
|
+
this.maxHeals = opts.maxHeals ?? 5;
|
|
40
|
+
}
|
|
41
|
+
async goto(url) {
|
|
42
|
+
return this.inner.goto(url);
|
|
43
|
+
}
|
|
44
|
+
async click(target) {
|
|
45
|
+
try {
|
|
46
|
+
await this.inner.click(target);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
const healed = await this.heal(target, err);
|
|
50
|
+
await this.inner.click({ text: healed });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async type(target, text) {
|
|
54
|
+
try {
|
|
55
|
+
await this.inner.type(target, text);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
const healed = await this.heal(target, err);
|
|
59
|
+
await this.inner.type({ text: healed }, text);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
snapshot() {
|
|
63
|
+
return this.inner.snapshot();
|
|
64
|
+
}
|
|
65
|
+
settle(options) {
|
|
66
|
+
return this.inner.settle(options);
|
|
67
|
+
}
|
|
68
|
+
observe() {
|
|
69
|
+
return this.inner.observe();
|
|
70
|
+
}
|
|
71
|
+
close() {
|
|
72
|
+
return this.inner.close();
|
|
73
|
+
}
|
|
74
|
+
async heal(target, cause) {
|
|
75
|
+
if (this.heals.length >= this.maxHeals) {
|
|
76
|
+
throw new Error(`self-heal budget (${this.maxHeals}) exhausted for ${JSON.stringify(target)}`);
|
|
77
|
+
}
|
|
78
|
+
const elements = await this.inner.snapshot();
|
|
79
|
+
const reply = await this.llm.complete(healPrompt(target, elements), { system: HEAL_SYSTEM });
|
|
80
|
+
const choice = parseHealChoice(reply);
|
|
81
|
+
if (!choice) {
|
|
82
|
+
const why = cause instanceof Error ? cause.message : String(cause);
|
|
83
|
+
throw new Error(`self-heal found no match for ${JSON.stringify(target)} (${why})`);
|
|
84
|
+
}
|
|
85
|
+
this.heals.push({ original: target, healedText: choice });
|
|
86
|
+
return choice;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=self-heal.js.map
|