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.
@@ -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,