agent-browser-loop 0.2.1 → 0.3.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/.claude/skills/agent-browser-loop/REFERENCE.md +131 -0
- package/.claude/skills/agent-browser-loop/SKILL.md +36 -4
- package/README.md +41 -17
- package/package.json +1 -1
- package/src/actions.ts +25 -19
- package/src/browser.ts +67 -9
- package/src/cli.ts +461 -9
- package/src/commands.ts +11 -0
- package/src/config.ts +1 -0
- package/src/daemon.ts +57 -26
- package/src/index.ts +18 -0
- package/src/profiles.ts +414 -0
- package/src/ref-store.ts +216 -0
- package/src/server.ts +148 -0
- package/src/state.ts +236 -132
- package/src/types.ts +2 -0
package/src/ref-store.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import type { Locator, Page } from "playwright";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Selector strategies for locating an element
|
|
5
|
+
* Multiple strategies provide resilience if one fails
|
|
6
|
+
*/
|
|
7
|
+
export interface ElementSelectors {
|
|
8
|
+
/** XPath from document root */
|
|
9
|
+
xpath: string;
|
|
10
|
+
/** CSS selector path */
|
|
11
|
+
cssPath: string;
|
|
12
|
+
/** Fingerprint-based selector using stable attributes */
|
|
13
|
+
fingerprint?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Stored reference to an element
|
|
18
|
+
*/
|
|
19
|
+
export interface StoredElementRef {
|
|
20
|
+
/** The ref string (e.g., "button_0") */
|
|
21
|
+
ref: string;
|
|
22
|
+
/** Sequential index */
|
|
23
|
+
index: number;
|
|
24
|
+
/** Multiple selector strategies */
|
|
25
|
+
selectors: ElementSelectors;
|
|
26
|
+
/** Element fingerprint for validation */
|
|
27
|
+
fingerprint: {
|
|
28
|
+
tagName: string;
|
|
29
|
+
role?: string;
|
|
30
|
+
type?: string;
|
|
31
|
+
name?: string;
|
|
32
|
+
placeholder?: string;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Server-side store for element references
|
|
38
|
+
* Avoids DOM modification that causes React hydration errors
|
|
39
|
+
*/
|
|
40
|
+
export class ElementRefStore {
|
|
41
|
+
private refMap = new Map<string, StoredElementRef>();
|
|
42
|
+
private indexMap = new Map<number, StoredElementRef>();
|
|
43
|
+
private snapshotVersion = 0;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Clear all stored refs (call before new snapshot)
|
|
47
|
+
*/
|
|
48
|
+
clear(): void {
|
|
49
|
+
this.refMap.clear();
|
|
50
|
+
this.indexMap.clear();
|
|
51
|
+
this.snapshotVersion++;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get current snapshot version
|
|
56
|
+
*/
|
|
57
|
+
getVersion(): number {
|
|
58
|
+
return this.snapshotVersion;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Store a ref for an element
|
|
63
|
+
*/
|
|
64
|
+
set(
|
|
65
|
+
ref: string,
|
|
66
|
+
index: number,
|
|
67
|
+
selectors: ElementSelectors,
|
|
68
|
+
fingerprint: StoredElementRef["fingerprint"],
|
|
69
|
+
): void {
|
|
70
|
+
const stored: StoredElementRef = { ref, index, selectors, fingerprint };
|
|
71
|
+
this.refMap.set(ref, stored);
|
|
72
|
+
this.indexMap.set(index, stored);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get stored ref by ref string
|
|
77
|
+
*/
|
|
78
|
+
getByRef(ref: string): StoredElementRef | undefined {
|
|
79
|
+
return this.refMap.get(ref);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get stored ref by index
|
|
84
|
+
*/
|
|
85
|
+
getByIndex(index: number): StoredElementRef | undefined {
|
|
86
|
+
return this.indexMap.get(index);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve a Playwright locator for an element by ref or index
|
|
91
|
+
*/
|
|
92
|
+
async resolveLocator(
|
|
93
|
+
page: Page,
|
|
94
|
+
options: { ref?: string; index?: number },
|
|
95
|
+
): Promise<Locator> {
|
|
96
|
+
let stored: StoredElementRef | undefined;
|
|
97
|
+
|
|
98
|
+
if (options.ref) {
|
|
99
|
+
stored = this.refMap.get(options.ref);
|
|
100
|
+
if (!stored) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`Unknown ref: ${options.ref}. Call getState() first to snapshot elements.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
} else if (options.index !== undefined) {
|
|
106
|
+
stored = this.indexMap.get(options.index);
|
|
107
|
+
if (!stored) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Unknown index: ${options.index}. Call getState() first to snapshot elements.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
throw new Error("Must provide either ref or index");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const pickMatching = async (locator: Locator): Promise<Locator | null> => {
|
|
117
|
+
const count = await locator.count();
|
|
118
|
+
if (count === 0) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (let i = 0; i < count; i++) {
|
|
123
|
+
const candidate = locator.nth(i);
|
|
124
|
+
const matches = await candidate.evaluate((el, fingerprint) => {
|
|
125
|
+
const element = el as HTMLElement;
|
|
126
|
+
if (
|
|
127
|
+
fingerprint.tagName &&
|
|
128
|
+
element.tagName.toLowerCase() !== fingerprint.tagName
|
|
129
|
+
) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
if (
|
|
133
|
+
fingerprint.role &&
|
|
134
|
+
element.getAttribute("role") !== fingerprint.role
|
|
135
|
+
) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
if (
|
|
139
|
+
fingerprint.type &&
|
|
140
|
+
element.getAttribute("type") !== fingerprint.type
|
|
141
|
+
) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
if (
|
|
145
|
+
fingerprint.name &&
|
|
146
|
+
element.getAttribute("name") !== fingerprint.name
|
|
147
|
+
) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
if (
|
|
151
|
+
fingerprint.placeholder &&
|
|
152
|
+
element.getAttribute("placeholder") !== fingerprint.placeholder
|
|
153
|
+
) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
}, stored!.fingerprint);
|
|
158
|
+
|
|
159
|
+
if (matches) {
|
|
160
|
+
return candidate;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return null;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const selectors = stored.selectors;
|
|
168
|
+
|
|
169
|
+
const xpathLocator = page.locator(`xpath=${selectors.xpath}`);
|
|
170
|
+
const xpathMatch = await pickMatching(xpathLocator);
|
|
171
|
+
if (xpathMatch) {
|
|
172
|
+
return xpathMatch;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const cssLocator = page.locator(selectors.cssPath);
|
|
176
|
+
const cssMatch = await pickMatching(cssLocator);
|
|
177
|
+
if (cssMatch) {
|
|
178
|
+
return cssMatch;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let fingerprintLocator: Locator | null = null;
|
|
182
|
+
if (selectors.fingerprint) {
|
|
183
|
+
const tagPrefix = stored.fingerprint.tagName || "";
|
|
184
|
+
const fingerprintSelector = selectors.fingerprint.startsWith("[")
|
|
185
|
+
? `${tagPrefix}${selectors.fingerprint}`
|
|
186
|
+
: selectors.fingerprint;
|
|
187
|
+
fingerprintLocator = page.locator(fingerprintSelector);
|
|
188
|
+
const fingerprintMatch = await pickMatching(fingerprintLocator);
|
|
189
|
+
if (fingerprintMatch) {
|
|
190
|
+
return fingerprintMatch;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Last resort: fall back to first match from the best available selector.
|
|
195
|
+
if (await xpathLocator.count()) {
|
|
196
|
+
return xpathLocator.first();
|
|
197
|
+
}
|
|
198
|
+
if (await cssLocator.count()) {
|
|
199
|
+
return cssLocator.first();
|
|
200
|
+
}
|
|
201
|
+
if (fingerprintLocator && (await fingerprintLocator.count())) {
|
|
202
|
+
return fingerprintLocator.first();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
throw new Error(
|
|
206
|
+
`Unable to resolve element for ref ${stored.ref}. Call getState() again to refresh element refs.`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Get all stored refs
|
|
212
|
+
*/
|
|
213
|
+
getAllRefs(): StoredElementRef[] {
|
|
214
|
+
return Array.from(this.refMap.values());
|
|
215
|
+
}
|
|
216
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -19,7 +19,16 @@ import {
|
|
|
19
19
|
} from "./commands";
|
|
20
20
|
import { createIdGenerator } from "./id";
|
|
21
21
|
import { log } from "./log";
|
|
22
|
+
import {
|
|
23
|
+
deleteProfile,
|
|
24
|
+
listProfiles,
|
|
25
|
+
loadProfile,
|
|
26
|
+
resolveProfilePath,
|
|
27
|
+
resolveStorageStateOption,
|
|
28
|
+
saveProfile,
|
|
29
|
+
} from "./profiles";
|
|
22
30
|
import { formatStateText } from "./state";
|
|
31
|
+
import type { StorageState } from "./types";
|
|
23
32
|
|
|
24
33
|
export interface BrowserServerConfig {
|
|
25
34
|
host?: string;
|
|
@@ -232,6 +241,7 @@ function getWaitCondition(data: WaitRequest): WaitCondition {
|
|
|
232
241
|
const createSessionBodySchema = z.object({
|
|
233
242
|
headless: z.boolean().optional(),
|
|
234
243
|
userDataDir: z.string().optional(),
|
|
244
|
+
profile: z.string().optional(),
|
|
235
245
|
});
|
|
236
246
|
|
|
237
247
|
const sessionParamsSchema = z.object({
|
|
@@ -528,16 +538,43 @@ const waitRoute = createRoute({
|
|
|
528
538
|
},
|
|
529
539
|
});
|
|
530
540
|
|
|
541
|
+
const closeSessionBodySchema = z
|
|
542
|
+
.object({
|
|
543
|
+
saveProfile: z.string().optional(),
|
|
544
|
+
global: z.boolean().optional(),
|
|
545
|
+
private: z.boolean().optional(),
|
|
546
|
+
})
|
|
547
|
+
.optional();
|
|
548
|
+
|
|
531
549
|
const closeRoute = createRoute({
|
|
532
550
|
method: "post",
|
|
533
551
|
path: "/session/{sessionId}/close",
|
|
534
552
|
request: {
|
|
535
553
|
params: sessionParamsSchema,
|
|
554
|
+
body: {
|
|
555
|
+
required: false,
|
|
556
|
+
content: {
|
|
557
|
+
"application/json": {
|
|
558
|
+
schema: closeSessionBodySchema,
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
},
|
|
536
562
|
},
|
|
537
563
|
responses: {
|
|
538
564
|
204: {
|
|
539
565
|
description: "Session closed",
|
|
540
566
|
},
|
|
567
|
+
200: {
|
|
568
|
+
description: "Session closed with profile saved",
|
|
569
|
+
content: {
|
|
570
|
+
"application/json": {
|
|
571
|
+
schema: z.object({
|
|
572
|
+
profileSaved: z.string().optional(),
|
|
573
|
+
profilePath: z.string().optional(),
|
|
574
|
+
}),
|
|
575
|
+
},
|
|
576
|
+
},
|
|
577
|
+
},
|
|
541
578
|
404: {
|
|
542
579
|
description: "Session not found",
|
|
543
580
|
content: {
|
|
@@ -745,6 +782,14 @@ export function startBrowserServer(config: BrowserServerConfig) {
|
|
|
745
782
|
overrides.userDataDir = body.userDataDir;
|
|
746
783
|
}
|
|
747
784
|
|
|
785
|
+
// Handle profile option
|
|
786
|
+
if (body?.profile) {
|
|
787
|
+
const storageState = resolveStorageStateOption(body.profile);
|
|
788
|
+
if (typeof storageState === "object") {
|
|
789
|
+
overrides.storageState = storageState;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
748
793
|
const id = await createNewSession(overrides);
|
|
749
794
|
return c.json({ sessionId: id }, 200);
|
|
750
795
|
},
|
|
@@ -886,11 +931,29 @@ export function startBrowserServer(config: BrowserServerConfig) {
|
|
|
886
931
|
app.openapi(closeRoute, async (c) => {
|
|
887
932
|
const { sessionId } = c.req.valid("param");
|
|
888
933
|
const session = getSessionOrThrow(sessions, sessionId);
|
|
934
|
+
const body = c.req.valid("json");
|
|
889
935
|
|
|
890
936
|
return withSession(session, async () => {
|
|
937
|
+
let profilePath: string | undefined;
|
|
938
|
+
|
|
939
|
+
// Save profile before closing if requested
|
|
940
|
+
if (body?.saveProfile) {
|
|
941
|
+
const storageState =
|
|
942
|
+
(await session.browser.saveStorageState()) as StorageState;
|
|
943
|
+
profilePath = saveProfile(body.saveProfile, storageState, {
|
|
944
|
+
global: body.global,
|
|
945
|
+
private: body.private,
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
891
949
|
await session.browser.stop();
|
|
892
950
|
sessions.delete(sessionId);
|
|
893
951
|
idGenerator.release(sessionId);
|
|
952
|
+
|
|
953
|
+
if (profilePath) {
|
|
954
|
+
return c.json({ profileSaved: body?.saveProfile, profilePath }, 200);
|
|
955
|
+
}
|
|
956
|
+
|
|
894
957
|
return c.body(null, 204);
|
|
895
958
|
});
|
|
896
959
|
});
|
|
@@ -905,6 +968,91 @@ export function startBrowserServer(config: BrowserServerConfig) {
|
|
|
905
968
|
});
|
|
906
969
|
});
|
|
907
970
|
|
|
971
|
+
// ========================================================================
|
|
972
|
+
// Profile Endpoints
|
|
973
|
+
// ========================================================================
|
|
974
|
+
|
|
975
|
+
// GET /profiles - list all profiles
|
|
976
|
+
app.get("/profiles", (c) => {
|
|
977
|
+
const profiles = listProfiles();
|
|
978
|
+
return c.json(profiles);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
// GET /profiles/:name - get profile contents
|
|
982
|
+
app.get("/profiles/:name", (c) => {
|
|
983
|
+
const name = c.req.param("name");
|
|
984
|
+
const profile = loadProfile(name);
|
|
985
|
+
if (!profile) {
|
|
986
|
+
return c.json({ error: `Profile not found: ${name}` }, 404);
|
|
987
|
+
}
|
|
988
|
+
const resolved = resolveProfilePath(name);
|
|
989
|
+
return c.json({
|
|
990
|
+
name,
|
|
991
|
+
scope: resolved?.scope,
|
|
992
|
+
path: resolved?.path,
|
|
993
|
+
profile,
|
|
994
|
+
});
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
// POST /profiles/:name - save profile from session or body
|
|
998
|
+
app.post("/profiles/:name", async (c) => {
|
|
999
|
+
const name = c.req.param("name");
|
|
1000
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1001
|
+
|
|
1002
|
+
let storageState: StorageState;
|
|
1003
|
+
|
|
1004
|
+
// If sessionId provided, get storage state from that session
|
|
1005
|
+
if (body.sessionId) {
|
|
1006
|
+
const session = sessions.get(body.sessionId);
|
|
1007
|
+
if (!session) {
|
|
1008
|
+
return c.json({ error: `Session not found: ${body.sessionId}` }, 404);
|
|
1009
|
+
}
|
|
1010
|
+
storageState = (await session.browser.saveStorageState()) as StorageState;
|
|
1011
|
+
} else if (body.cookies || body.origins) {
|
|
1012
|
+
// Direct storage state in body
|
|
1013
|
+
storageState = {
|
|
1014
|
+
cookies: body.cookies || [],
|
|
1015
|
+
origins: body.origins || [],
|
|
1016
|
+
};
|
|
1017
|
+
} else {
|
|
1018
|
+
return c.json(
|
|
1019
|
+
{
|
|
1020
|
+
error: "Either sessionId or storage state (cookies/origins) required",
|
|
1021
|
+
},
|
|
1022
|
+
400,
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const savedPath = saveProfile(name, storageState, {
|
|
1027
|
+
global: body.global,
|
|
1028
|
+
private: body.private,
|
|
1029
|
+
description: body.description,
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
return c.json({
|
|
1033
|
+
name,
|
|
1034
|
+
path: savedPath,
|
|
1035
|
+
cookies: storageState.cookies.length,
|
|
1036
|
+
origins: storageState.origins.length,
|
|
1037
|
+
});
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
// DELETE /profiles/:name - delete profile
|
|
1041
|
+
app.delete("/profiles/:name", (c) => {
|
|
1042
|
+
const name = c.req.param("name");
|
|
1043
|
+
const resolved = resolveProfilePath(name);
|
|
1044
|
+
if (!resolved) {
|
|
1045
|
+
return c.json({ error: `Profile not found: ${name}` }, 404);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const deleted = deleteProfile(name);
|
|
1049
|
+
if (!deleted) {
|
|
1050
|
+
return c.json({ error: `Failed to delete profile: ${name}` }, 500);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
return c.json({ deleted: name, path: resolved.path });
|
|
1054
|
+
});
|
|
1055
|
+
|
|
908
1056
|
const server = Bun.serve({
|
|
909
1057
|
hostname: host,
|
|
910
1058
|
port,
|