@yibie/pi-jev-browser 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/LICENSE +201 -0
- package/README.md +137 -0
- package/extensions/jev-browser.ts +466 -0
- package/package.json +56 -0
- package/pi-jev-browser.config.example.json +23 -0
- package/src/actions.ts +205 -0
- package/src/browser-setup.ts +45 -0
- package/src/config.ts +118 -0
- package/src/credentials.ts +26 -0
- package/src/jev-browser.ts +456 -0
- package/src/jev-model.ts +107 -0
- package/src/jev-run.ts +334 -0
- package/src/pi-model.ts +167 -0
- package/src/recording-overlay.ts +82 -0
- package/src/runtime.ts +588 -0
- package/src/stream.ts +132 -0
- package/src/types.ts +79 -0
- package/src/typesafe.ts +137 -0
- package/test/browser-setup.test.ts +32 -0
- package/test/credentials.test.ts +53 -0
- package/test/extension.test.ts +180 -0
- package/test/jev.test.ts +559 -0
- package/test/navigation-observation.test.ts +94 -0
- package/test/pi-model.test.ts +148 -0
- package/test/runtime.test.ts +121 -0
- package/test/smoke-config.json +17 -0
- package/test/typesafe.test.ts +129 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Browser, BrowserContext, Page, Video } from "playwright";
|
|
2
|
+
|
|
3
|
+
export type PolicyName = "pi" | "typesafe";
|
|
4
|
+
|
|
5
|
+
export interface JevBrowserConfig {
|
|
6
|
+
policy: PolicyName;
|
|
7
|
+
allowedOrigins: string[];
|
|
8
|
+
headless: boolean;
|
|
9
|
+
recordVideo: boolean;
|
|
10
|
+
showCursor: boolean;
|
|
11
|
+
showClickIndicators: boolean;
|
|
12
|
+
outputDir: string;
|
|
13
|
+
viewport: { width: number; height: number };
|
|
14
|
+
stream: { enabled: boolean; intervalMs: number };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type BrowserAction =
|
|
18
|
+
| {
|
|
19
|
+
type: "click" | "double_click";
|
|
20
|
+
x: number;
|
|
21
|
+
y: number;
|
|
22
|
+
button?: "left" | "right" | "wheel";
|
|
23
|
+
keys?: string[];
|
|
24
|
+
}
|
|
25
|
+
| { type: "scroll"; x?: number; y?: number; deltaX: number; deltaY: number }
|
|
26
|
+
| { type: "type"; text: string }
|
|
27
|
+
| { type: "wait"; ms?: number }
|
|
28
|
+
| { type: "keypress"; keys: string[] }
|
|
29
|
+
| {
|
|
30
|
+
type: "drag";
|
|
31
|
+
path: Array<{ x: number; y: number } | [number, number]>;
|
|
32
|
+
button?: "left" | "right" | "wheel";
|
|
33
|
+
}
|
|
34
|
+
| { type: "move"; x: number; y: number }
|
|
35
|
+
| { type: "screenshot" }
|
|
36
|
+
| { type: "navigate"; url: string }
|
|
37
|
+
| { type: "back" | "forward" | "reload" };
|
|
38
|
+
|
|
39
|
+
export interface BrowserLogEntry {
|
|
40
|
+
id: number;
|
|
41
|
+
timestamp: string;
|
|
42
|
+
type:
|
|
43
|
+
| "console"
|
|
44
|
+
| "pageerror"
|
|
45
|
+
| "requestfailed"
|
|
46
|
+
| "download"
|
|
47
|
+
| "navigation"
|
|
48
|
+
| "security";
|
|
49
|
+
level: string;
|
|
50
|
+
text: string;
|
|
51
|
+
url?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface BrowserState {
|
|
55
|
+
active: boolean;
|
|
56
|
+
currentUrl?: string;
|
|
57
|
+
pageTitle?: string;
|
|
58
|
+
pages: Array<{ index: number; title: string; url: string }>;
|
|
59
|
+
startedAt?: string;
|
|
60
|
+
viewport: { width: number; height: number };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ActiveBrowserSession {
|
|
64
|
+
browser: Browser;
|
|
65
|
+
context: BrowserContext;
|
|
66
|
+
page: Page;
|
|
67
|
+
video?: Video;
|
|
68
|
+
id: string;
|
|
69
|
+
outputDir: string;
|
|
70
|
+
startedAt: string;
|
|
71
|
+
logs: BrowserLogEntry[];
|
|
72
|
+
nextLogId: number;
|
|
73
|
+
stream?: StreamController;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface StreamController {
|
|
77
|
+
url: string;
|
|
78
|
+
stop(): Promise<void>;
|
|
79
|
+
}
|
package/src/typesafe.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { Observation, ObservedTarget } from "./jev-browser.ts";
|
|
2
|
+
import { buildQuestions, type Decision, type JevPolicy, parseText } from "./jev-model.ts";
|
|
3
|
+
import { buildTextPrompt, type ModelCall, TEXT_SYSTEM } from "./pi-model.ts";
|
|
4
|
+
|
|
5
|
+
export const TYPESAFE_ENDPOINT = "https://api.typesafe.ai/v1/systemone";
|
|
6
|
+
export const DEFAULT_TYPESAFE_MODEL = "jev-latest";
|
|
7
|
+
|
|
8
|
+
export interface TypesafeChoice {
|
|
9
|
+
choice: string;
|
|
10
|
+
probability?: number;
|
|
11
|
+
confidence?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The TypeSafe API returns a choice, a probability for every offered option, and
|
|
16
|
+
* a confidence for the selected one. Only the choice is load-bearing: an answer
|
|
17
|
+
* that names no offered option tells the loop nothing, so that is an error. A
|
|
18
|
+
* distribution the loop cannot fully trust must never kill a run that has
|
|
19
|
+
already taken actions, so anything else about it degrades to "probability
|
|
20
|
+
* unknown" instead.
|
|
21
|
+
*/
|
|
22
|
+
export function parseChoiceAnswer(
|
|
23
|
+
value: unknown,
|
|
24
|
+
valid: readonly string[],
|
|
25
|
+
): TypesafeChoice {
|
|
26
|
+
const answers = (value as { answers?: unknown } | null | undefined)?.answers;
|
|
27
|
+
if (!answers || typeof answers !== "object")
|
|
28
|
+
throw new Error("TypeSafe returned no answers.");
|
|
29
|
+
const action = (answers as Record<string, unknown>).action;
|
|
30
|
+
if (!action || typeof action !== "object")
|
|
31
|
+
throw new Error("TypeSafe returned no answer for the action question.");
|
|
32
|
+
const { type, choice, probabilities, confidence } = action as {
|
|
33
|
+
type?: unknown;
|
|
34
|
+
choice?: unknown;
|
|
35
|
+
probabilities?: unknown;
|
|
36
|
+
confidence?: unknown;
|
|
37
|
+
};
|
|
38
|
+
if (type !== "choice")
|
|
39
|
+
throw new Error(
|
|
40
|
+
`TypeSafe answered the action question with type ${describe(type)}.`,
|
|
41
|
+
);
|
|
42
|
+
if (typeof choice !== "string" || !valid.includes(choice))
|
|
43
|
+
throw new Error(
|
|
44
|
+
`TypeSafe selected an unoffered option (${describe(choice)}). Offered: ${valid.slice(0, 10).join(", ")}${valid.length > 10 ? ", …" : ""}.`,
|
|
45
|
+
);
|
|
46
|
+
return {
|
|
47
|
+
choice,
|
|
48
|
+
probability: reportedProbability(probabilities, choice),
|
|
49
|
+
confidence:
|
|
50
|
+
typeof confidence === "number" && Number.isFinite(confidence)
|
|
51
|
+
? confidence
|
|
52
|
+
: undefined,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The selected option's own probability, if it is a usable number. */
|
|
57
|
+
function reportedProbability(value: unknown, choice: string): number | undefined {
|
|
58
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
59
|
+
return undefined;
|
|
60
|
+
const reported = (value as Record<string, unknown>)[choice];
|
|
61
|
+
return typeof reported === "number" &&
|
|
62
|
+
Number.isFinite(reported) &&
|
|
63
|
+
reported >= 0 &&
|
|
64
|
+
reported <= 1
|
|
65
|
+
? reported
|
|
66
|
+
: undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function describe(value: unknown) {
|
|
70
|
+
return (JSON.stringify(value) ?? String(value)).slice(0, 60);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Direct TypeSafe transport: no gateway, no second vendor. Jev generates no
|
|
75
|
+
* text, so field values still come from a model — the one pi has configured. */
|
|
76
|
+
export function createTypesafePolicy(options: {
|
|
77
|
+
apiKey: string;
|
|
78
|
+
model?: string;
|
|
79
|
+
text: ModelCall;
|
|
80
|
+
fetchImpl?: typeof fetch;
|
|
81
|
+
}): JevPolicy {
|
|
82
|
+
const model = options.model ?? DEFAULT_TYPESAFE_MODEL;
|
|
83
|
+
const request = options.fetchImpl ?? fetch;
|
|
84
|
+
return {
|
|
85
|
+
async choose(observation, goal, history, signal): Promise<Decision> {
|
|
86
|
+
const questions = buildQuestions(observation, goal);
|
|
87
|
+
const response = await request(TYPESAFE_ENDPOINT, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: {
|
|
90
|
+
authorization: `Bearer ${options.apiKey}`,
|
|
91
|
+
"content-type": "application/json",
|
|
92
|
+
},
|
|
93
|
+
body: JSON.stringify({
|
|
94
|
+
state: JSON.stringify({
|
|
95
|
+
page: observation,
|
|
96
|
+
recentActions: history.slice(-10),
|
|
97
|
+
}),
|
|
98
|
+
model,
|
|
99
|
+
questions,
|
|
100
|
+
}),
|
|
101
|
+
signal,
|
|
102
|
+
});
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
// Keep the body out of the message only when it is unreadable; the
|
|
105
|
+
// status code is what the loop classifies.
|
|
106
|
+
const detail = await response.text().catch(() => "");
|
|
107
|
+
throw Object.assign(
|
|
108
|
+
new Error(
|
|
109
|
+
`TypeSafe request failed with ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`,
|
|
110
|
+
),
|
|
111
|
+
{ statusCode: response.status },
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const { choice, probability, confidence } = parseChoiceAnswer(
|
|
115
|
+
await response.json(),
|
|
116
|
+
Object.keys(questions.action.criteria),
|
|
117
|
+
);
|
|
118
|
+
const target: ObservedTarget | undefined = observation.targets.find(
|
|
119
|
+
(entry) => `${entry.operation}:${entry.id}` === choice,
|
|
120
|
+
);
|
|
121
|
+
return {
|
|
122
|
+
operation: target?.operation ?? choice,
|
|
123
|
+
target,
|
|
124
|
+
probability,
|
|
125
|
+
providerConfidence: confidence,
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
async text(observation, goal, target, history, signal) {
|
|
129
|
+
const answer = await options.text({
|
|
130
|
+
system: TEXT_SYSTEM,
|
|
131
|
+
prompt: buildTextPrompt(observation, goal, target, history),
|
|
132
|
+
signal,
|
|
133
|
+
});
|
|
134
|
+
return parseText(answer);
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createBrowserSetup } from "../src/browser-setup.ts";
|
|
4
|
+
|
|
5
|
+
test("browser setup shares installation and caches success", async () => {
|
|
6
|
+
let calls = 0;
|
|
7
|
+
let finish!: () => void;
|
|
8
|
+
const ensure = createBrowserSetup(async () => {
|
|
9
|
+
calls++;
|
|
10
|
+
await new Promise<void>((resolve) => {
|
|
11
|
+
finish = resolve;
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
const first = ensure();
|
|
15
|
+
assert.equal(ensure(), first);
|
|
16
|
+
await Promise.resolve();
|
|
17
|
+
assert.equal(calls, 1);
|
|
18
|
+
finish();
|
|
19
|
+
await first;
|
|
20
|
+
await ensure();
|
|
21
|
+
assert.equal(calls, 1);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("failed browser setup reports failure and allows a later retry", async () => {
|
|
25
|
+
let calls = 0;
|
|
26
|
+
const ensure = createBrowserSetup(async () => {
|
|
27
|
+
if (++calls === 1) throw new Error("offline");
|
|
28
|
+
});
|
|
29
|
+
await assert.rejects(ensure(), /offline/);
|
|
30
|
+
await ensure();
|
|
31
|
+
assert.equal(calls, 2);
|
|
32
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { readTypesafeCredentials } from "../src/credentials.ts";
|
|
7
|
+
|
|
8
|
+
test("credential file handles JSON syntax, precedence, reloads and missing keys", () => {
|
|
9
|
+
const directory = mkdtempSync(join(tmpdir(), "jev-credentials-"));
|
|
10
|
+
const path = join(directory, "config.json");
|
|
11
|
+
try {
|
|
12
|
+
assert.throws(() => readTypesafeCredentials({ path, env: {} }), /TYPESAFE_API_KEY/);
|
|
13
|
+
writeFileSync(
|
|
14
|
+
path,
|
|
15
|
+
JSON.stringify({
|
|
16
|
+
typesafe: { apiKey: "file-test-key", model: "jev-latest" },
|
|
17
|
+
}),
|
|
18
|
+
{ mode: 0o600 },
|
|
19
|
+
);
|
|
20
|
+
assert.deepEqual(readTypesafeCredentials({ path, env: {} }), {
|
|
21
|
+
apiKey: "file-test-key",
|
|
22
|
+
model: "jev-latest",
|
|
23
|
+
});
|
|
24
|
+
assert.deepEqual(
|
|
25
|
+
readTypesafeCredentials({
|
|
26
|
+
path,
|
|
27
|
+
env: { TYPESAFE_API_KEY: "env-test-key", TYPESAFE_MODEL: "other-model" },
|
|
28
|
+
}),
|
|
29
|
+
{ apiKey: "env-test-key", model: "other-model" },
|
|
30
|
+
);
|
|
31
|
+
// A blank environment value must not shadow a configured file value.
|
|
32
|
+
assert.equal(
|
|
33
|
+
readTypesafeCredentials({ path, env: { TYPESAFE_API_KEY: " " } }).apiKey,
|
|
34
|
+
"file-test-key",
|
|
35
|
+
);
|
|
36
|
+
// The model falls back to the current default when nothing sets it.
|
|
37
|
+
writeFileSync(path, JSON.stringify({ typesafe: { apiKey: "changed-test-key" } }));
|
|
38
|
+
assert.deepEqual(readTypesafeCredentials({ path, env: {} }), {
|
|
39
|
+
apiKey: "changed-test-key",
|
|
40
|
+
model: "jev-latest",
|
|
41
|
+
});
|
|
42
|
+
writeFileSync(path, "{}");
|
|
43
|
+
assert.throws(() => readTypesafeCredentials({ path, env: {} }), /TYPESAFE_API_KEY/);
|
|
44
|
+
writeFileSync(path, "{invalid");
|
|
45
|
+
assert.throws(() => readTypesafeCredentials({ path, env: {} }), /JSON syntax/);
|
|
46
|
+
assert.throws(
|
|
47
|
+
() => readTypesafeCredentials({ path: directory, env: {} }),
|
|
48
|
+
/Cannot read/,
|
|
49
|
+
);
|
|
50
|
+
} finally {
|
|
51
|
+
rmSync(directory, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import extension, {
|
|
8
|
+
modelAcceptsImages,
|
|
9
|
+
verificationHint,
|
|
10
|
+
} from "../extensions/jev-browser.ts";
|
|
11
|
+
import { normalizeKey } from "../src/actions.ts";
|
|
12
|
+
import { isUrlAllowed, readConfig } from "../src/config.ts";
|
|
13
|
+
|
|
14
|
+
interface RegisteredTool {
|
|
15
|
+
name: string;
|
|
16
|
+
description: string;
|
|
17
|
+
promptSnippet?: string;
|
|
18
|
+
promptGuidelines?: string[];
|
|
19
|
+
executionMode?: string;
|
|
20
|
+
parameters: {
|
|
21
|
+
type: string;
|
|
22
|
+
required?: string[];
|
|
23
|
+
properties: Record<string, any>;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function loadExtension() {
|
|
28
|
+
const tools = new Map<string, RegisteredTool>();
|
|
29
|
+
const events = new Map<string, unknown>();
|
|
30
|
+
extension({
|
|
31
|
+
registerTool(tool: RegisteredTool) {
|
|
32
|
+
tools.set(tool.name, tool);
|
|
33
|
+
},
|
|
34
|
+
on(event: string, handler: unknown) {
|
|
35
|
+
events.set(event, handler);
|
|
36
|
+
},
|
|
37
|
+
} as unknown as ExtensionAPI);
|
|
38
|
+
return { tools, events };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test("registers the complete Jev Browser surface", () => {
|
|
42
|
+
const { tools, events } = loadExtension();
|
|
43
|
+
assert.deepEqual([...tools.keys()], [
|
|
44
|
+
"jev_run",
|
|
45
|
+
"jev_actions",
|
|
46
|
+
"jev_state",
|
|
47
|
+
"jev_logs",
|
|
48
|
+
"jev_stream",
|
|
49
|
+
"jev_stop",
|
|
50
|
+
]);
|
|
51
|
+
// The browser must be released when pi replaces or exits the session.
|
|
52
|
+
assert.ok(events.has("session_shutdown"));
|
|
53
|
+
// Serialized: these drive one shared page, and pi may call tools concurrently.
|
|
54
|
+
for (const name of ["jev_run", "jev_actions", "jev_stream", "jev_stop"])
|
|
55
|
+
assert.equal(tools.get(name)?.executionMode, "sequential", name);
|
|
56
|
+
// Safety rules live on the tools they constrain, and name them.
|
|
57
|
+
for (const tool of tools.values())
|
|
58
|
+
for (const guideline of tool.promptGuidelines ?? [])
|
|
59
|
+
assert.ok(
|
|
60
|
+
guideline.includes(tool.name) || guideline.includes("Jev"),
|
|
61
|
+
`guideline must name its tool: ${guideline.slice(0, 60)}`,
|
|
62
|
+
);
|
|
63
|
+
const guidelines = tools.get("jev_run")?.promptGuidelines ?? [];
|
|
64
|
+
assert.ok(guidelines.some((line) => line.includes("done_unverified")));
|
|
65
|
+
assert.ok(guidelines.some((line) => line.includes("jev_stop")));
|
|
66
|
+
assert.ok(
|
|
67
|
+
(tools.get("jev_stop")?.promptGuidelines ?? []).some((line) =>
|
|
68
|
+
line.includes("jev_stop"),
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
assert.ok(
|
|
72
|
+
(tools.get("jev_actions")?.promptGuidelines ?? []).some((line) =>
|
|
73
|
+
line.includes("never use it automatically"),
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("tool schemas keep the Jev Browser bounds", () => {
|
|
79
|
+
const { tools } = loadExtension();
|
|
80
|
+
const run = tools.get("jev_run")!.parameters;
|
|
81
|
+
assert.equal(run.type, "object");
|
|
82
|
+
assert.deepEqual(run.required, ["goal"]);
|
|
83
|
+
assert.equal(run.properties.goal.maxLength, 12_000);
|
|
84
|
+
assert.equal(run.properties.maxSteps.maximum, 60);
|
|
85
|
+
assert.equal(run.properties.minProbability.maximum, 1);
|
|
86
|
+
|
|
87
|
+
const actions = tools.get("jev_actions")!.parameters;
|
|
88
|
+
assert.deepEqual(actions.required, ["actions"]);
|
|
89
|
+
assert.equal(actions.properties.actions.maxItems, 50);
|
|
90
|
+
assert.equal(actions.properties.includeScreenshot.type, "boolean");
|
|
91
|
+
|
|
92
|
+
// Google rejects unions and literals in tool schemas, so enums stay flat.
|
|
93
|
+
assert.deepEqual(tools.get("jev_stream")!.parameters.properties.action, {
|
|
94
|
+
type: "string",
|
|
95
|
+
enum: ["start", "status", "stop"],
|
|
96
|
+
});
|
|
97
|
+
assert.ok(
|
|
98
|
+
(actions.properties.actions.items.properties.type as { enum: string[] }).enum.includes(
|
|
99
|
+
"double_click",
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
const drag = actions.properties.actions.items.properties.path;
|
|
103
|
+
assert.match(JSON.stringify(drag), /"maxItems":2/);
|
|
104
|
+
assert.equal(tools.get("jev_state")!.parameters.type, "object");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("verification does not depend on the model being able to see images", () => {
|
|
108
|
+
assert.equal(modelAcceptsImages({ input: ["text", "image"] }), true);
|
|
109
|
+
assert.equal(modelAcceptsImages({ input: ["text"] }), false);
|
|
110
|
+
assert.equal(modelAcceptsImages(undefined), false);
|
|
111
|
+
|
|
112
|
+
const visual = verificationHint(true);
|
|
113
|
+
const textual = verificationHint(false);
|
|
114
|
+
for (const hint of [visual, textual])
|
|
115
|
+
assert.match(hint, /done_unverified is a claim/, hint.slice(0, 40));
|
|
116
|
+
// Pi turns blocked images into this exact placeholder, so the model can name the cause.
|
|
117
|
+
assert.match(visual, /Image reading is disabled/);
|
|
118
|
+
assert.match(visual, /blockImages/);
|
|
119
|
+
// Without vision the run still has to be checkable, and status alone is not enough.
|
|
120
|
+
assert.match(textual, /cannot receive images/);
|
|
121
|
+
assert.match(textual, /final page text/);
|
|
122
|
+
assert.match(textual, /Never report success from the status alone/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("matches configured origins and blocks unsupported schemes", () => {
|
|
126
|
+
const patterns = ["https://*.example.com", "http://localhost:*"];
|
|
127
|
+
assert.equal(isUrlAllowed("https://app.example.com/path", patterns), true);
|
|
128
|
+
assert.equal(isUrlAllowed("http://localhost:4173", patterns), true);
|
|
129
|
+
assert.equal(isUrlAllowed("https://example.net", patterns), false);
|
|
130
|
+
assert.equal(isUrlAllowed("file:///etc/passwd", ["*"]), false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("loads bounded config values", () => {
|
|
134
|
+
const directory = mkdtempSync(join(tmpdir(), "pi-jev-browser-"));
|
|
135
|
+
const path = join(directory, "config.json");
|
|
136
|
+
try {
|
|
137
|
+
writeFileSync(
|
|
138
|
+
path,
|
|
139
|
+
JSON.stringify({
|
|
140
|
+
policy: "typesafe",
|
|
141
|
+
allowedOrigins: ["https://example.com"],
|
|
142
|
+
viewport: { width: 99, height: 9999 },
|
|
143
|
+
stream: { enabled: true, intervalMs: 10 },
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
const config = readConfig(path);
|
|
147
|
+
assert.equal(config.policy, "typesafe");
|
|
148
|
+
assert.deepEqual(config.allowedOrigins, ["https://example.com"]);
|
|
149
|
+
assert.deepEqual(config.viewport, { width: 640, height: 1600 });
|
|
150
|
+
assert.deepEqual(config.stream, { enabled: true, intervalMs: 250 });
|
|
151
|
+
} finally {
|
|
152
|
+
rmSync(directory, { recursive: true, force: true });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("uses complete defaults when configuration is omitted", () => {
|
|
157
|
+
const config = readConfig(join(tmpdir(), "missing-pi-jev-browser.config.json"));
|
|
158
|
+
// pi's own model by default: no second credential, no extra API quota.
|
|
159
|
+
assert.equal(config.policy, "pi");
|
|
160
|
+
assert.deepEqual(config.allowedOrigins, ["http://*", "https://*"]);
|
|
161
|
+
assert.equal(config.headless, true);
|
|
162
|
+
assert.equal(config.recordVideo, true);
|
|
163
|
+
assert.equal(config.showCursor, true);
|
|
164
|
+
assert.equal(config.showClickIndicators, true);
|
|
165
|
+
assert.deepEqual(config.viewport, { width: 1280, height: 720 });
|
|
166
|
+
assert.deepEqual(config.stream, { enabled: false, intervalMs: 1000 });
|
|
167
|
+
assert.equal(isUrlAllowed("https://openai.com", config.allowedOrigins), true);
|
|
168
|
+
assert.equal(
|
|
169
|
+
isUrlAllowed("http://example.test:8080", config.allowedOrigins),
|
|
170
|
+
true,
|
|
171
|
+
);
|
|
172
|
+
assert.equal(isUrlAllowed("file:///etc/passwd", config.allowedOrigins), false);
|
|
173
|
+
assert.match(config.outputDir, /\.pi\/agent\/data\/jev-browser$/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("normalizes Jev Browser key aliases", () => {
|
|
177
|
+
assert.equal(normalizeKey("CTRL"), "Control");
|
|
178
|
+
assert.equal(normalizeKey("ARROWDOWN"), "ArrowDown");
|
|
179
|
+
assert.equal(normalizeKey("a"), "a");
|
|
180
|
+
});
|