hyper-agent-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.
@@ -0,0 +1,172 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { z } from "zod";
6
+
7
+ export const SessionSchema = z.object({
8
+ name: z.string(),
9
+ status: z.enum(["running", "stopped"]),
10
+ channel: z.enum(["chrome", "msedge", "chromium"]),
11
+ userDataDir: z.string(),
12
+ url: z.string().optional(),
13
+ title: z.string().optional(),
14
+ pid: z.number().optional(),
15
+ wsEndpoint: z.string().optional(),
16
+ createdAt: z.number(),
17
+ lastActiveAt: z.number(),
18
+ });
19
+
20
+ export type Session = z.infer<typeof SessionSchema>;
21
+
22
+ export class SessionStore {
23
+ private baseDir: string;
24
+
25
+ constructor(baseDir?: string) {
26
+ this.baseDir = baseDir || join(homedir(), ".hab", "sessions");
27
+ }
28
+
29
+ private getSessionDir(name: string): string {
30
+ return join(this.baseDir, name);
31
+ }
32
+
33
+ private getSessionFile(name: string): string {
34
+ return join(this.getSessionDir(name), "session.json");
35
+ }
36
+
37
+ private getUserDataDir(name: string): string {
38
+ return join(this.getSessionDir(name), "userdata");
39
+ }
40
+
41
+ async ensureBaseDir(): Promise<void> {
42
+ if (!existsSync(this.baseDir)) {
43
+ await mkdir(this.baseDir, { recursive: true, mode: 0o700 });
44
+ }
45
+ }
46
+
47
+ async exists(name: string): Promise<boolean> {
48
+ return existsSync(this.getSessionFile(name));
49
+ }
50
+
51
+ async load(name: string): Promise<Session | null> {
52
+ const file = this.getSessionFile(name);
53
+ if (!existsSync(file)) {
54
+ return null;
55
+ }
56
+
57
+ try {
58
+ const content = await readFile(file, "utf-8");
59
+ const data = JSON.parse(content);
60
+ return SessionSchema.parse(data);
61
+ } catch (error) {
62
+ console.error(`Failed to load session ${name}:`, error);
63
+ return null;
64
+ }
65
+ }
66
+
67
+ async save(session: Session): Promise<void> {
68
+ await this.ensureBaseDir();
69
+ const sessionDir = this.getSessionDir(session.name);
70
+
71
+ if (!existsSync(sessionDir)) {
72
+ await mkdir(sessionDir, { recursive: true, mode: 0o700 });
73
+ }
74
+
75
+ const file = this.getSessionFile(session.name);
76
+ const content = JSON.stringify(session, null, 2);
77
+ await writeFile(file, content, "utf-8");
78
+
79
+ // Security: Set file permissions to 0o600 (owner read/write only)
80
+ // Protects wsEndpoint from being read by other processes
81
+ const { chmod } = await import("node:fs/promises");
82
+ await chmod(file, 0o600);
83
+ }
84
+
85
+ async create(name: string, channel: "chrome" | "msedge" | "chromium"): Promise<Session> {
86
+ await this.ensureBaseDir();
87
+
88
+ const sessionDir = this.getSessionDir(name);
89
+ const userDataDir = this.getUserDataDir(name);
90
+
91
+ if (!existsSync(sessionDir)) {
92
+ await mkdir(sessionDir, { recursive: true, mode: 0o700 });
93
+ }
94
+
95
+ if (!existsSync(userDataDir)) {
96
+ await mkdir(userDataDir, { recursive: true, mode: 0o700 });
97
+ }
98
+
99
+ const now = Date.now();
100
+ const session: Session = {
101
+ name,
102
+ status: "stopped",
103
+ channel,
104
+ userDataDir,
105
+ createdAt: now,
106
+ lastActiveAt: now,
107
+ };
108
+
109
+ await this.save(session);
110
+ return session;
111
+ }
112
+
113
+ async update(name: string, updates: Partial<Session>): Promise<Session | null> {
114
+ const session = await this.load(name);
115
+ if (!session) {
116
+ return null;
117
+ }
118
+
119
+ const updated = {
120
+ ...session,
121
+ ...updates,
122
+ lastActiveAt: Date.now(),
123
+ };
124
+
125
+ await this.save(updated);
126
+ return updated;
127
+ }
128
+
129
+ async list(): Promise<Session[]> {
130
+ await this.ensureBaseDir();
131
+
132
+ if (!existsSync(this.baseDir)) {
133
+ return [];
134
+ }
135
+
136
+ const sessions: Session[] = [];
137
+
138
+ // Bun's readdir with withFileTypes not fully compatible, fallback to simple approach
139
+ const { readdirSync } = await import("node:fs");
140
+ const dirs = readdirSync(this.baseDir, { withFileTypes: true });
141
+
142
+ for (const entry of dirs) {
143
+ if (entry.isDirectory()) {
144
+ const session = await this.load(entry.name);
145
+ if (session) {
146
+ sessions.push(session);
147
+ }
148
+ }
149
+ }
150
+
151
+ return sessions.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
152
+ }
153
+
154
+ async delete(name: string): Promise<boolean> {
155
+ const sessionDir = this.getSessionDir(name);
156
+ if (!existsSync(sessionDir)) {
157
+ return false;
158
+ }
159
+
160
+ const { rmSync } = await import("node:fs");
161
+ rmSync(sessionDir, { recursive: true, force: true });
162
+ return true;
163
+ }
164
+
165
+ async updateActivity(name: string): Promise<void> {
166
+ const session = await this.load(name);
167
+ if (session) {
168
+ session.lastActiveAt = Date.now();
169
+ await this.save(session);
170
+ }
171
+ }
172
+ }
@@ -0,0 +1,182 @@
1
+ import type { Page } from "patchright";
2
+
3
+ export interface SnapshotElement {
4
+ ref: string;
5
+ role: string;
6
+ name: string;
7
+ value?: string;
8
+ description?: string;
9
+ checked?: boolean;
10
+ selected?: boolean;
11
+ disabled?: boolean;
12
+ focused?: boolean;
13
+ required?: boolean;
14
+ boundingBox?: {
15
+ x: number;
16
+ y: number;
17
+ width: number;
18
+ height: number;
19
+ };
20
+ }
21
+
22
+ export interface Snapshot {
23
+ url: string;
24
+ title: string;
25
+ timestamp: number;
26
+ elements: SnapshotElement[];
27
+ }
28
+
29
+ const INTERACTIVE_ROLES = new Set([
30
+ "button",
31
+ "link",
32
+ "textbox",
33
+ "checkbox",
34
+ "radio",
35
+ "combobox",
36
+ "menuitem",
37
+ "tab",
38
+ "searchbox",
39
+ "spinbutton",
40
+ "slider",
41
+ "switch",
42
+ "option",
43
+ ]);
44
+
45
+ interface AccessibilityNode {
46
+ role?: string;
47
+ name?: string;
48
+ value?: string | number;
49
+ description?: string;
50
+ checked?: boolean | "mixed";
51
+ disabled?: boolean;
52
+ expanded?: boolean;
53
+ focused?: boolean;
54
+ level?: number;
55
+ modal?: boolean;
56
+ multiline?: boolean;
57
+ multiselectable?: boolean;
58
+ orientation?: string;
59
+ placeholder?: string;
60
+ pressed?: boolean | "mixed";
61
+ readonly?: boolean;
62
+ required?: boolean;
63
+ selected?: boolean;
64
+ valuemax?: number;
65
+ valuemin?: number;
66
+ valuenow?: number;
67
+ valuetext?: string;
68
+ children?: AccessibilityNode[];
69
+ }
70
+
71
+ export class AccessibilityExtractor {
72
+ private elementCounter = 0;
73
+ private elementMap = new Map<string, SnapshotElement>();
74
+
75
+ private async getAccessibilitySnapshot(page: Page): Promise<AccessibilityNode | null> {
76
+ try {
77
+ // Try to use accessibility API if available
78
+ return await (page as any).accessibility?.snapshot();
79
+ } catch {
80
+ // Fallback: return null, we'll implement alternative extraction later
81
+ return null;
82
+ }
83
+ }
84
+
85
+ async extract(page: Page, interactiveOnly = true): Promise<Snapshot> {
86
+ this.elementCounter = 0;
87
+ this.elementMap.clear();
88
+
89
+ const url = page.url();
90
+ const title = await page.title();
91
+ const timestamp = Date.now();
92
+
93
+ // Get accessibility tree
94
+ // Note: Using locator API as fallback since accessibility API may not be available
95
+ const snapshot = await this.getAccessibilitySnapshot(page);
96
+ if (!snapshot) {
97
+ return { url, title, timestamp, elements: [] };
98
+ }
99
+
100
+ // Extract elements
101
+ const elements = this.extractElements(snapshot, interactiveOnly);
102
+
103
+ return {
104
+ url,
105
+ title,
106
+ timestamp,
107
+ elements,
108
+ };
109
+ }
110
+
111
+ private extractElements(node: AccessibilityNode, interactiveOnly: boolean): SnapshotElement[] {
112
+ const elements: SnapshotElement[] = [];
113
+
114
+ if (this.shouldInclude(node, interactiveOnly)) {
115
+ const element = this.nodeToElement(node);
116
+ if (element) {
117
+ elements.push(element);
118
+ }
119
+ }
120
+
121
+ // Recursively process children
122
+ if (node.children) {
123
+ for (const child of node.children) {
124
+ elements.push(...this.extractElements(child, interactiveOnly));
125
+ }
126
+ }
127
+
128
+ return elements;
129
+ }
130
+
131
+ private shouldInclude(node: AccessibilityNode, interactiveOnly: boolean): boolean {
132
+ if (!node.role) {
133
+ return false;
134
+ }
135
+
136
+ if (interactiveOnly) {
137
+ return INTERACTIVE_ROLES.has(node.role);
138
+ }
139
+
140
+ return true;
141
+ }
142
+
143
+ private nodeToElement(node: AccessibilityNode): SnapshotElement | null {
144
+ if (!node.role) {
145
+ return null;
146
+ }
147
+
148
+ this.elementCounter++;
149
+ const ref = `e${this.elementCounter}`;
150
+
151
+ const element: SnapshotElement = {
152
+ ref,
153
+ role: node.role,
154
+ name: node.name || "",
155
+ value: this.formatValue(node.value),
156
+ description: node.description,
157
+ checked: typeof node.checked === "boolean" ? node.checked : undefined,
158
+ selected: node.selected,
159
+ disabled: node.disabled,
160
+ focused: node.focused,
161
+ required: node.required,
162
+ };
163
+
164
+ this.elementMap.set(ref, element);
165
+ return element;
166
+ }
167
+
168
+ private formatValue(value: string | number | undefined): string | undefined {
169
+ if (value === undefined || value === null) {
170
+ return undefined;
171
+ }
172
+ return String(value);
173
+ }
174
+
175
+ getElementByRef(ref: string): SnapshotElement | undefined {
176
+ return this.elementMap.get(ref);
177
+ }
178
+
179
+ getAllElements(): SnapshotElement[] {
180
+ return Array.from(this.elementMap.values());
181
+ }
182
+ }
@@ -0,0 +1,220 @@
1
+ import type { Page } from "patchright";
2
+ import type { SnapshotElement } from "./accessibility";
3
+
4
+ /**
5
+ * Fallback snapshot extractor using DOM traversal
6
+ * Used when accessibility API is not available
7
+ */
8
+ export class DomSnapshotExtractor {
9
+ private elementCounter = 0;
10
+ private elementMap = new Map<string, { selector: string; element: SnapshotElement }>();
11
+
12
+ async extract(page: Page, interactiveOnly = true): Promise<SnapshotElement[]> {
13
+ this.elementCounter = 0;
14
+ this.elementMap.clear();
15
+
16
+ // Inject script to extract interactive elements from DOM
17
+ const elements = await page.evaluate((interactiveOnly) => {
18
+ const interactiveRoles = new Set([
19
+ "button",
20
+ "link",
21
+ "textbox",
22
+ "checkbox",
23
+ "radio",
24
+ "combobox",
25
+ "menuitem",
26
+ "tab",
27
+ "searchbox",
28
+ ]);
29
+
30
+ const results: any[] = [];
31
+
32
+ // Helper to get role from element
33
+ function getRole(el: Element): string | null {
34
+ // Check ARIA role
35
+ const ariaRole = el.getAttribute("role");
36
+ if (ariaRole) return ariaRole;
37
+
38
+ // Infer from tag name
39
+ const tagName = el.tagName.toLowerCase();
40
+ switch (tagName) {
41
+ case "button":
42
+ return "button";
43
+ case "a":
44
+ return "link";
45
+ case "input": {
46
+ const type = (el as HTMLInputElement).type.toLowerCase();
47
+ if (type === "checkbox") return "checkbox";
48
+ if (type === "radio") return "radio";
49
+ if (type === "submit" || type === "button") return "button";
50
+ return "textbox";
51
+ }
52
+ case "textarea":
53
+ return "textbox";
54
+ case "select":
55
+ return "combobox";
56
+ default:
57
+ return null;
58
+ }
59
+ }
60
+
61
+ // Helper to get accessible name
62
+ function getName(el: Element): string {
63
+ // Check aria-label
64
+ const ariaLabel = el.getAttribute("aria-label");
65
+ if (ariaLabel) return ariaLabel;
66
+
67
+ // Check aria-labelledby
68
+ const labelledBy = el.getAttribute("aria-labelledby");
69
+ if (labelledBy) {
70
+ const labelEl = document.getElementById(labelledBy);
71
+ if (labelEl) return labelEl.textContent?.trim() || "";
72
+ }
73
+
74
+ // Check associated label
75
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
76
+ const labels = (el as any).labels;
77
+ if (labels && labels.length > 0) {
78
+ return labels[0].textContent?.trim() || "";
79
+ }
80
+ }
81
+
82
+ // Check placeholder
83
+ const placeholder = el.getAttribute("placeholder");
84
+ if (placeholder) return placeholder;
85
+
86
+ // Use text content for buttons and links
87
+ if (el.tagName === "BUTTON" || el.tagName === "A") {
88
+ return el.textContent?.trim() || "";
89
+ }
90
+
91
+ // Use value for inputs
92
+ if (el instanceof HTMLInputElement) {
93
+ return el.value || "";
94
+ }
95
+
96
+ return "";
97
+ }
98
+
99
+ // Helper to generate CSS selector
100
+ function getSelector(el: Element): string {
101
+ // Try ID first
102
+ if (el.id) return `#${el.id}`;
103
+
104
+ // Generate nth-child selector
105
+ let selector = el.tagName.toLowerCase();
106
+ let current: Element | null = el;
107
+
108
+ while (current?.parentElement) {
109
+ const parent: Element = current.parentElement;
110
+ const siblings = Array.from(parent.children);
111
+ const index = siblings.indexOf(current);
112
+
113
+ if (index >= 0) {
114
+ selector = `${parent.tagName.toLowerCase()} > ${selector}:nth-child(${index + 1})`;
115
+ }
116
+
117
+ current = parent;
118
+ if (current?.id) {
119
+ selector = `#${current.id} ${selector}`;
120
+ break;
121
+ }
122
+
123
+ // Limit depth
124
+ if (selector.split(">").length > 5) break;
125
+ }
126
+
127
+ return selector;
128
+ }
129
+
130
+ // Traverse DOM
131
+ function traverse(el: Element) {
132
+ const role = getRole(el);
133
+
134
+ if (role && (!interactiveOnly || interactiveRoles.has(role))) {
135
+ const name = getName(el);
136
+ const selector = getSelector(el);
137
+
138
+ // Get element properties
139
+ const result: any = {
140
+ role,
141
+ name,
142
+ selector,
143
+ disabled: (el as any).disabled || false,
144
+ focused: el === document.activeElement,
145
+ required: (el as any).required || false,
146
+ };
147
+
148
+ // Get value for inputs
149
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
150
+ result.value = el.value;
151
+ }
152
+
153
+ // Get checked state for checkboxes/radios
154
+ if (el instanceof HTMLInputElement) {
155
+ if (el.type === "checkbox" || el.type === "radio") {
156
+ result.checked = el.checked;
157
+ }
158
+ }
159
+
160
+ results.push(result);
161
+ }
162
+
163
+ // Recurse to children
164
+ for (const child of Array.from(el.children)) {
165
+ traverse(child);
166
+ }
167
+ }
168
+
169
+ traverse(document.body);
170
+ return results;
171
+ }, interactiveOnly);
172
+
173
+ // Convert to SnapshotElement format with @eN references
174
+ const snapshotElements: SnapshotElement[] = [];
175
+
176
+ for (const el of elements) {
177
+ this.elementCounter++;
178
+ const ref = `e${this.elementCounter}`;
179
+
180
+ const snapshotElement: SnapshotElement = {
181
+ ref,
182
+ role: el.role,
183
+ name: el.name,
184
+ value: el.value,
185
+ checked: el.checked,
186
+ disabled: el.disabled,
187
+ focused: el.focused,
188
+ required: el.required,
189
+ };
190
+
191
+ snapshotElements.push(snapshotElement);
192
+
193
+ // Store mapping from ref to selector
194
+ this.elementMap.set(ref, {
195
+ selector: el.selector,
196
+ element: snapshotElement,
197
+ });
198
+ }
199
+
200
+ return snapshotElements;
201
+ }
202
+
203
+ /**
204
+ * Get CSS selector for an element reference
205
+ */
206
+ getSelector(ref: string): string | undefined {
207
+ return this.elementMap.get(ref)?.selector;
208
+ }
209
+
210
+ /**
211
+ * Get all element references and their selectors
212
+ */
213
+ getAllMappings(): Map<string, string> {
214
+ const mappings = new Map<string, string>();
215
+ for (const [ref, { selector }] of this.elementMap.entries()) {
216
+ mappings.set(ref, selector);
217
+ }
218
+ return mappings;
219
+ }
220
+ }
@@ -0,0 +1,115 @@
1
+ import type { Snapshot, SnapshotElement } from "./accessibility";
2
+
3
+ export interface FormatOptions {
4
+ maxElements?: number;
5
+ includeDisabled?: boolean;
6
+ }
7
+
8
+ export class SnapshotFormatter {
9
+ format(snapshot: Snapshot, options: FormatOptions = {}): string {
10
+ const lines: string[] = [];
11
+
12
+ // Header
13
+ lines.push(`URL: ${snapshot.url}`);
14
+ lines.push(`Title: ${snapshot.title}`);
15
+ lines.push("");
16
+
17
+ // Elements
18
+ let elements = snapshot.elements;
19
+
20
+ // Filter disabled elements if needed
21
+ if (!options.includeDisabled) {
22
+ elements = elements.filter((el) => !el.disabled);
23
+ }
24
+
25
+ // Limit elements
26
+ if (options.maxElements && elements.length > options.maxElements) {
27
+ elements = elements.slice(0, options.maxElements);
28
+ lines.push(
29
+ `Interactive Elements (showing ${options.maxElements} of ${snapshot.elements.length}):`,
30
+ );
31
+ } else {
32
+ lines.push("Interactive Elements:");
33
+ }
34
+
35
+ // Format each element
36
+ for (const element of elements) {
37
+ lines.push(this.formatElement(element));
38
+ }
39
+
40
+ if (elements.length === 0) {
41
+ lines.push(" (no interactive elements found)");
42
+ }
43
+
44
+ return lines.join("\n");
45
+ }
46
+
47
+ formatElement(element: SnapshotElement): string {
48
+ const parts: string[] = [];
49
+
50
+ // Reference
51
+ parts.push(`@${element.ref}`);
52
+
53
+ // Role
54
+ parts.push(`[${element.role}]`.padEnd(12));
55
+
56
+ // Name
57
+ if (element.name) {
58
+ parts.push(`"${element.name}"`);
59
+ }
60
+
61
+ // Value
62
+ if (element.value && element.value !== element.name) {
63
+ parts.push(`value="${element.value}"`);
64
+ }
65
+
66
+ // States
67
+ const states: string[] = [];
68
+ if (element.focused) states.push("focused");
69
+ if (element.checked !== undefined) {
70
+ states.push(element.checked ? "checked" : "unchecked");
71
+ }
72
+ if (element.selected) states.push("selected");
73
+ if (element.disabled) states.push("disabled");
74
+ if (element.required) states.push("required");
75
+
76
+ if (states.length > 0) {
77
+ parts.push(`(${states.join(", ")})`);
78
+ }
79
+
80
+ return parts.join(" ");
81
+ }
82
+
83
+ formatJson(snapshot: Snapshot, pretty = true): string {
84
+ return JSON.stringify(snapshot, null, pretty ? 2 : 0);
85
+ }
86
+
87
+ formatCompact(snapshot: Snapshot): string {
88
+ const lines: string[] = [];
89
+ lines.push(`${snapshot.url} | ${snapshot.title}`);
90
+ lines.push(`Elements: ${snapshot.elements.length}`);
91
+ return lines.join("\n");
92
+ }
93
+
94
+ formatSummary(snapshot: Snapshot): string {
95
+ const roleCounts = new Map<string, number>();
96
+
97
+ for (const element of snapshot.elements) {
98
+ const count = roleCounts.get(element.role) || 0;
99
+ roleCounts.set(element.role, count + 1);
100
+ }
101
+
102
+ const lines: string[] = [];
103
+ lines.push(`URL: ${snapshot.url}`);
104
+ lines.push(`Title: ${snapshot.title}`);
105
+ lines.push("");
106
+ lines.push("Element Summary:");
107
+
108
+ const sorted = Array.from(roleCounts.entries()).sort((a, b) => b[1] - a[1]);
109
+ for (const [role, count] of sorted) {
110
+ lines.push(` ${role}: ${count}`);
111
+ }
112
+
113
+ return lines.join("\n");
114
+ }
115
+ }