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.
- package/LICENSE +21 -0
- package/README.md +196 -0
- package/package.json +63 -0
- package/src/browser/context.ts +66 -0
- package/src/browser/manager.ts +414 -0
- package/src/browser/sync-chrome-data.ts +53 -0
- package/src/cli.ts +628 -0
- package/src/cli.ts.backup +529 -0
- package/src/commands/actions.ts +232 -0
- package/src/commands/advanced.ts +252 -0
- package/src/commands/config.ts +44 -0
- package/src/commands/getters.ts +110 -0
- package/src/commands/info.ts +195 -0
- package/src/commands/navigation.ts +50 -0
- package/src/commands/session.ts +83 -0
- package/src/daemon/browser-pool.ts +65 -0
- package/src/daemon/client.ts +128 -0
- package/src/daemon/main.ts +200 -0
- package/src/daemon/server.ts +562 -0
- package/src/session/manager.ts +110 -0
- package/src/session/store.ts +172 -0
- package/src/snapshot/accessibility.ts +182 -0
- package/src/snapshot/dom-extractor.ts +220 -0
- package/src/snapshot/formatter.ts +115 -0
- package/src/snapshot/reference-store.ts +97 -0
- package/src/utils/config.ts +183 -0
- package/src/utils/errors.ts +121 -0
- package/src/utils/logger.ts +12 -0
- package/src/utils/selector.ts +23 -0
|
@@ -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
|
+
}
|