effect-browserbase 0.2.0-beta.8 → 0.3.0-beta.10
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/README.md +277 -315
- package/dist/Browserbase-CaPKwEY9.mjs +544 -0
- package/dist/Browserbase-CaPKwEY9.mjs.map +1 -0
- package/dist/Browserbase.d.mts +110 -0
- package/dist/Browserbase.mjs +3 -0
- package/dist/BrowserbaseClient-CtAQ2rg4.d.mts +195 -0
- package/dist/BrowserbaseClient.d.mts +2 -0
- package/dist/BrowserbaseClient.mjs +190 -0
- package/dist/BrowserbaseClient.mjs.map +1 -0
- package/dist/BrowserbaseError-D1EOrLNc.d.mts +79 -0
- package/dist/BrowserbaseError.d.mts +2 -0
- package/dist/BrowserbaseError.mjs +101 -0
- package/dist/BrowserbaseError.mjs.map +1 -0
- package/dist/ContextLease.d.mts +42 -0
- package/dist/ContextLease.mjs +80 -0
- package/dist/ContextLease.mjs.map +1 -0
- package/dist/index.d.mts +5 -27
- package/dist/index.mjs +5 -26
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/testing/index.d.mts +136 -0
- package/dist/testing/index.mjs +440 -0
- package/dist/testing/index.mjs.map +1 -0
- package/package.json +51 -121
- package/src/Browserbase.ts +729 -0
- package/src/BrowserbaseClient.ts +559 -0
- package/src/BrowserbaseError.ts +108 -0
- package/src/ContextLease.ts +118 -0
- package/src/index.ts +7 -0
- package/src/internal/captureConnection.ts +214 -0
- package/src/testing/BrowserbaseContract.ts +170 -0
- package/src/testing/TestBrowserbase.ts +509 -0
- package/src/testing/index.ts +7 -0
- package/dist/Account.d.mts +0 -32
- package/dist/Account.mjs +0 -36
- package/dist/Account.mjs.map +0 -1
- package/dist/Acquisition-BOABu_qJ.mjs +0 -244
- package/dist/Acquisition-BOABu_qJ.mjs.map +0 -1
- package/dist/Agents-CPaTOH_g.d.mts +0 -156
- package/dist/Agents.d.mts +0 -3
- package/dist/Agents.mjs +0 -292
- package/dist/Agents.mjs.map +0 -1
- package/dist/Allocation.d.mts +0 -97
- package/dist/Allocation.mjs +0 -27
- package/dist/Allocation.mjs.map +0 -1
- package/dist/Authorization-Ll5f_XNM.mjs +0 -12
- package/dist/Authorization-Ll5f_XNM.mjs.map +0 -1
- package/dist/Browser-ByrWJ0ss.mjs +0 -274
- package/dist/Browser-ByrWJ0ss.mjs.map +0 -1
- package/dist/Browser.d.mts +0 -83
- package/dist/Browser.mjs +0 -3
- package/dist/BrowserBinding.d.mts +0 -13
- package/dist/BrowserBinding.mjs +0 -31
- package/dist/BrowserBinding.mjs.map +0 -1
- package/dist/Certificates-D4NQmyeA.d.mts +0 -34
- package/dist/Certificates.d.mts +0 -3
- package/dist/Certificates.mjs +0 -85
- package/dist/Certificates.mjs.map +0 -1
- package/dist/Cleanup-DMTDBir8.d.mts +0 -23
- package/dist/Cleanup.d.mts +0 -3
- package/dist/Cleanup.mjs +0 -43
- package/dist/Cleanup.mjs.map +0 -1
- package/dist/Client-BEC4Q8ZS.mjs +0 -564
- package/dist/Client-BEC4Q8ZS.mjs.map +0 -1
- package/dist/Client-B_f6bBLT.d.mts +0 -52
- package/dist/Client.d.mts +0 -3
- package/dist/Client.mjs +0 -2
- package/dist/Connection-sxyxtwlk.mjs +0 -56
- package/dist/Connection-sxyxtwlk.mjs.map +0 -1
- package/dist/ContextCoordination.d.mts +0 -29
- package/dist/ContextCoordination.mjs +0 -94
- package/dist/ContextCoordination.mjs.map +0 -1
- package/dist/ContextWriter-CiEma24z.mjs +0 -204
- package/dist/ContextWriter-CiEma24z.mjs.map +0 -1
- package/dist/Contexts-DqdL7cyX.d.mts +0 -31
- package/dist/Contexts.d.mts +0 -3
- package/dist/Contexts.mjs +0 -97
- package/dist/Contexts.mjs.map +0 -1
- package/dist/Deadline-CrViaogr.mjs +0 -13
- package/dist/Deadline-CrViaogr.mjs.map +0 -1
- package/dist/Downloads-CX52GOFJ.d.mts +0 -43
- package/dist/Downloads.d.mts +0 -3
- package/dist/Downloads.mjs +0 -137
- package/dist/Downloads.mjs.map +0 -1
- package/dist/Errors.d.mts +0 -100
- package/dist/Errors.mjs +0 -194
- package/dist/Errors.mjs.map +0 -1
- package/dist/Extensions-BTUn9zhK.mjs +0 -231
- package/dist/Extensions-BTUn9zhK.mjs.map +0 -1
- package/dist/Extensions-DBXuVM1f.d.mts +0 -43
- package/dist/Extensions.d.mts +0 -3
- package/dist/Extensions.mjs +0 -2
- package/dist/Functions-CsKMMUuZ.d.mts +0 -226
- package/dist/Functions.d.mts +0 -3
- package/dist/Functions.mjs +0 -318
- package/dist/Functions.mjs.map +0 -1
- package/dist/Issued-DejqoMYL.mjs +0 -15
- package/dist/Issued-DejqoMYL.mjs.map +0 -1
- package/dist/Launch-cAdE2rrZ.mjs +0 -115
- package/dist/Launch-cAdE2rrZ.mjs.map +0 -1
- package/dist/Launch.d.mts +0 -127
- package/dist/Launch.mjs +0 -97
- package/dist/Launch.mjs.map +0 -1
- package/dist/PageFetch-CjAM_0DS.d.mts +0 -40
- package/dist/PageFetch.d.mts +0 -3
- package/dist/PageFetch.mjs +0 -59
- package/dist/PageFetch.mjs.map +0 -1
- package/dist/Projects-tm5bTDH0.d.mts +0 -38
- package/dist/Projects.d.mts +0 -3
- package/dist/Projects.mjs +0 -65
- package/dist/Projects.mjs.map +0 -1
- package/dist/Recordings-B9k5aODB.d.mts +0 -27
- package/dist/Recordings.d.mts +0 -3
- package/dist/Recordings.mjs +0 -144
- package/dist/Recordings.mjs.map +0 -1
- package/dist/References-DOH3Wu44.d.mts +0 -34
- package/dist/References.d.mts +0 -3
- package/dist/References.mjs +0 -33
- package/dist/References.mjs.map +0 -1
- package/dist/Replays-mV93WGtx.d.mts +0 -27
- package/dist/Replays.d.mts +0 -3
- package/dist/Replays.mjs +0 -107
- package/dist/Replays.mjs.map +0 -1
- package/dist/Resource-J7Kkk5mu.mjs +0 -54
- package/dist/Resource-J7Kkk5mu.mjs.map +0 -1
- package/dist/Search-B64e7g-h.d.mts +0 -37
- package/dist/Search.d.mts +0 -3
- package/dist/Search.mjs +0 -46
- package/dist/Search.mjs.map +0 -1
- package/dist/SessionData-5xYZDshJ.d.mts +0 -38
- package/dist/SessionData.d.mts +0 -3
- package/dist/SessionData.mjs +0 -48
- package/dist/SessionData.mjs.map +0 -1
- package/dist/Sessions-D2H5m6rF.mjs +0 -260
- package/dist/Sessions-D2H5m6rF.mjs.map +0 -1
- package/dist/Sessions-oJEUmZth.d.mts +0 -69
- package/dist/Sessions.d.mts +0 -4
- package/dist/Sessions.mjs +0 -3
- package/dist/Testing.d.mts +0 -119
- package/dist/Testing.mjs +0 -206
- package/dist/Testing.mjs.map +0 -1
- package/dist/TransferPolicy-DQ06i7SZ.mjs +0 -25
- package/dist/TransferPolicy-DQ06i7SZ.mjs.map +0 -1
- package/dist/Transfers-GVqzrDWl.d.mts +0 -89
- package/dist/Transfers.d.mts +0 -3
- package/dist/Transfers.mjs +0 -85
- package/dist/Transfers.mjs.map +0 -1
- package/dist/Uploads-C2_bACgv.d.mts +0 -28
- package/dist/Uploads.d.mts +0 -3
- package/dist/Uploads.mjs +0 -105
- package/dist/Uploads.mjs.map +0 -1
- package/dist/Webhooks-BoeRAxzG.d.mts +0 -59
- package/dist/Webhooks.d.mts +0 -3
- package/dist/Webhooks.mjs +0 -134
- package/dist/Webhooks.mjs.map +0 -1
- package/dist/WriterFacts-BBJRDZYY.d.mts +0 -54
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may write a stored context. A session that persists to a context saves to it when the
|
|
3
|
+
* session ends, so two at once can lose one's changes. `Browserbase.open` holds a context through
|
|
4
|
+
* its `ContextLease` from before it creates a persisting session until that session has ended and
|
|
5
|
+
* its save has settled, and the next writer waits. `verifyContext` and `reconcile` hold it too.
|
|
6
|
+
*
|
|
7
|
+
* The lease is a port. `layer` holds contexts within the process that builds it. An application
|
|
8
|
+
* whose writers run in several processes provides its own, such as an advisory lock in its
|
|
9
|
+
* database, and so excludes them all.
|
|
10
|
+
*
|
|
11
|
+
* A hold also passes on how the writer before left the context: `unsettled` while a session that
|
|
12
|
+
* saves to it may still run, as when that writer's release was left unconfirmed, its create's answer
|
|
13
|
+
* was lost, or it kept its session past its scope. The next writer then ends the context's sessions
|
|
14
|
+
* before it writes. A writer says how it leaves the context once, as it lets the hold go; a hold
|
|
15
|
+
* that never says, as when its process ends first, must read as `unsettled` to the next.
|
|
16
|
+
*
|
|
17
|
+
* @since 0.3.0
|
|
18
|
+
*/
|
|
19
|
+
import { Context, Effect, Layer, Schema, type Scope, Semaphore } from "effect";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The stored context could not be held for writing: a session that saves to it may still run, as
|
|
23
|
+
* its sessions could not be confirmed ended, or the lease itself could not be taken. The next try
|
|
24
|
+
* clears it again.
|
|
25
|
+
*/
|
|
26
|
+
export class ContextHeld extends Schema.TaggedError<ContextHeld>()("ContextHeld", {
|
|
27
|
+
context: Schema.String,
|
|
28
|
+
detail: Schema.String,
|
|
29
|
+
}) {
|
|
30
|
+
override get message() {
|
|
31
|
+
return `context ${this.context} is held: ${this.detail}`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A writer's hold on a stored context, for the scope `hold` was given. */
|
|
36
|
+
export interface Hold {
|
|
37
|
+
/** Whether a session that saves to the context may still run, as the writer before left it. */
|
|
38
|
+
readonly unsettled: boolean;
|
|
39
|
+
/** Say how this writer leaves the context, once, before the hold goes: the next writer reads it. */
|
|
40
|
+
readonly leave: (unsettled: boolean) => Effect.Effect<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface Service {
|
|
44
|
+
/**
|
|
45
|
+
* Hold the context as its only writer until the scope closes, waiting while another holds it.
|
|
46
|
+
* It fails with `ContextHeld` when the lease cannot be taken, as when its own store is down.
|
|
47
|
+
*/
|
|
48
|
+
readonly hold: (context: string) => Effect.Effect<Hold, ContextHeld, Scope.Scope>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class ContextLease extends Context.Service<ContextLease, Service>()(
|
|
52
|
+
"effect-browserbase/ContextLease",
|
|
53
|
+
) {}
|
|
54
|
+
|
|
55
|
+
interface Entry {
|
|
56
|
+
readonly lock: Semaphore.Semaphore;
|
|
57
|
+
unsettled: boolean;
|
|
58
|
+
/** Its holder and the writers waiting for it. */
|
|
59
|
+
users: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The lease within this process: one writer at a time for each stored context. A context it has
|
|
64
|
+
* not held is presumed settled, so writers in other processes are the application's to exclude,
|
|
65
|
+
* with a lease of its own. It forgets a context once nobody holds it, or waits for it, and it was
|
|
66
|
+
* left settled.
|
|
67
|
+
*/
|
|
68
|
+
export const layer: Layer.Layer<ContextLease> = Layer.sync(ContextLease, () => {
|
|
69
|
+
const contexts = new Map<string, Entry>();
|
|
70
|
+
|
|
71
|
+
const enter = (context: string) => {
|
|
72
|
+
const entry = contexts.get(context) ?? {
|
|
73
|
+
lock: Semaphore.makeUnsafe(1),
|
|
74
|
+
unsettled: false,
|
|
75
|
+
users: 0,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
entry.users += 1;
|
|
79
|
+
contexts.set(context, entry);
|
|
80
|
+
|
|
81
|
+
return entry;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const exit = (context: string, entry: Entry) => {
|
|
85
|
+
entry.users -= 1;
|
|
86
|
+
if (entry.users === 0 && !entry.unsettled) contexts.delete(context);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return ContextLease.of({
|
|
90
|
+
hold: (context) =>
|
|
91
|
+
Effect.gen(function* () {
|
|
92
|
+
const entry = yield* Effect.acquireRelease(
|
|
93
|
+
Effect.sync(() => enter(context)),
|
|
94
|
+
(entered) => Effect.sync(() => exit(context, entered)),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// Unless the holder says otherwise, it leaves a session that saves to the context running.
|
|
98
|
+
let leaving = true;
|
|
99
|
+
|
|
100
|
+
yield* Effect.acquireRelease(
|
|
101
|
+
entry.lock.take(1),
|
|
102
|
+
() =>
|
|
103
|
+
Effect.sync(() => {
|
|
104
|
+
entry.unsettled = leaving;
|
|
105
|
+
}).pipe(Effect.andThen(entry.lock.release(1))),
|
|
106
|
+
{ interruptible: true },
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
unsettled: entry.unsettled,
|
|
111
|
+
leave: (unsettled) =>
|
|
112
|
+
Effect.sync(() => {
|
|
113
|
+
leaving = unsettled;
|
|
114
|
+
}),
|
|
115
|
+
} satisfies Hold;
|
|
116
|
+
}),
|
|
117
|
+
});
|
|
118
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The capture connection: a second DevTools connection to a session, for its pages' screencasts
|
|
3
|
+
* alone. A frame and its acknowledgement then never wait behind a large message on the
|
|
4
|
+
* connection Playwright drives the pages over, such as an upload or a read's answer: Browserbase
|
|
5
|
+
* refuses compression, and on air those waits stopped the picture for as long as the message took
|
|
6
|
+
* to cross.
|
|
7
|
+
*
|
|
8
|
+
* Raw CDP over the global WebSocket, with no Playwright. A page's session on it sends only the
|
|
9
|
+
* capture's commands, and receives that page's events. The connection opens as the first capture
|
|
10
|
+
* starts and stays until the scope closes, which closes it and waits, bounded, for it to close;
|
|
11
|
+
* one that fails ends every page's session on it, and the next capture opens another.
|
|
12
|
+
*/
|
|
13
|
+
import { Duration, Effect, Option, Redacted, Schema, Semaphore } from "effect";
|
|
14
|
+
import type { CaptureSource } from "effect-browser/Browser";
|
|
15
|
+
import { BrowserError, Failed } from "effect-browser/BrowserError";
|
|
16
|
+
|
|
17
|
+
const Message = Schema.fromJsonString(
|
|
18
|
+
Schema.Struct({
|
|
19
|
+
id: Schema.optionalKey(Schema.Int),
|
|
20
|
+
sessionId: Schema.optionalKey(Schema.String),
|
|
21
|
+
method: Schema.optionalKey(Schema.String),
|
|
22
|
+
params: Schema.optionalKey(Schema.Unknown),
|
|
23
|
+
result: Schema.optionalKey(Schema.Unknown),
|
|
24
|
+
error: Schema.optionalKey(Schema.Struct({ message: Schema.String })),
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const Session = Schema.Struct({ sessionId: Schema.String });
|
|
29
|
+
|
|
30
|
+
type Listener = Parameters<CaptureSource["attach"]>[1];
|
|
31
|
+
|
|
32
|
+
interface Open {
|
|
33
|
+
readonly socket: WebSocket;
|
|
34
|
+
/** Replies awaited, by command id. */
|
|
35
|
+
readonly replies: Map<
|
|
36
|
+
number,
|
|
37
|
+
{ readonly resolve: (result: unknown) => void; readonly reject: (error: BrowserError) => void }
|
|
38
|
+
>;
|
|
39
|
+
/** Each attached page's listener, by its session id. */
|
|
40
|
+
readonly sessions: Map<string, Listener>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type State =
|
|
44
|
+
| { readonly _tag: "Idle" }
|
|
45
|
+
| { readonly _tag: "Open"; readonly open: Open }
|
|
46
|
+
| { readonly _tag: "Closed" };
|
|
47
|
+
|
|
48
|
+
const failure = (detail: string) =>
|
|
49
|
+
new BrowserError({ operation: "screencast", reason: new Failed({ detail }), dispatched: false });
|
|
50
|
+
|
|
51
|
+
const closing = Duration.seconds(2);
|
|
52
|
+
|
|
53
|
+
/** The capture connection to the DevTools address `url`, for the scope. */
|
|
54
|
+
export const make = Effect.fnUntraced(function* (url: Redacted.Redacted<string>) {
|
|
55
|
+
let state: State = { _tag: "Idle" };
|
|
56
|
+
let ids = 0;
|
|
57
|
+
const opening = yield* Semaphore.make(1);
|
|
58
|
+
|
|
59
|
+
// A failure ends every page's session on the connection; the next capture opens another.
|
|
60
|
+
const lose = (open: Open, error: BrowserError) => {
|
|
61
|
+
if (state._tag !== "Open" || state.open !== open) return;
|
|
62
|
+
state = { _tag: "Idle" };
|
|
63
|
+
for (const reply of open.replies.values()) reply.reject(error);
|
|
64
|
+
for (const listener of open.sessions.values()) listener.lost(error);
|
|
65
|
+
open.replies.clear();
|
|
66
|
+
open.sessions.clear();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const receive = (open: Open, data: unknown) => {
|
|
70
|
+
const decoded = typeof data === "string" ? Schema.decodeOption(Message)(data) : Option.none();
|
|
71
|
+
|
|
72
|
+
if (Option.isNone(decoded))
|
|
73
|
+
return lose(open, failure("the capture connection sent a message it could not read"));
|
|
74
|
+
const { id, sessionId, method, params, result, error } = decoded.value;
|
|
75
|
+
|
|
76
|
+
if (id !== undefined) {
|
|
77
|
+
const reply = open.replies.get(id);
|
|
78
|
+
|
|
79
|
+
open.replies.delete(id);
|
|
80
|
+
if (error === undefined) reply?.resolve(result);
|
|
81
|
+
else reply?.reject(failure(error.message));
|
|
82
|
+
} else if (method === undefined) return;
|
|
83
|
+
else if (sessionId !== undefined) open.sessions.get(sessionId)?.event(method, params);
|
|
84
|
+
// The connection's own session announces that a page's session ended.
|
|
85
|
+
else if (method === "Target.detachedFromTarget") {
|
|
86
|
+
const detached = Schema.decodeUnknownOption(Session)(params);
|
|
87
|
+
const ended = Option.isSome(detached) ? detached.value.sessionId : "";
|
|
88
|
+
const listener = open.sessions.get(ended);
|
|
89
|
+
|
|
90
|
+
open.sessions.delete(ended);
|
|
91
|
+
listener?.event(method, params);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const send = (
|
|
96
|
+
open: Open,
|
|
97
|
+
method: string,
|
|
98
|
+
params: Record<string, unknown> | undefined,
|
|
99
|
+
sessionId?: string,
|
|
100
|
+
) => {
|
|
101
|
+
if (state._tag !== "Open" || state.open !== open)
|
|
102
|
+
return Promise.reject(failure("the capture connection was lost"));
|
|
103
|
+
const id = ++ids;
|
|
104
|
+
const { promise, resolve, reject } = Promise.withResolvers<unknown>();
|
|
105
|
+
|
|
106
|
+
open.replies.set(id, { resolve, reject });
|
|
107
|
+
try {
|
|
108
|
+
open.socket.send(JSON.stringify({ id, method, params, sessionId }));
|
|
109
|
+
} catch {
|
|
110
|
+
open.replies.delete(id);
|
|
111
|
+
reject(failure("the capture connection could not send"));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return promise;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const connect = Effect.callback<Open, BrowserError>((resume) => {
|
|
118
|
+
const socket = new WebSocket(Redacted.value(url));
|
|
119
|
+
const open: Open = { socket, replies: new Map(), sessions: new Map() };
|
|
120
|
+
|
|
121
|
+
const unopened = () => resume(Effect.fail(failure("the capture connection did not open")));
|
|
122
|
+
|
|
123
|
+
socket.addEventListener("close", unopened, { once: true });
|
|
124
|
+
socket.addEventListener(
|
|
125
|
+
"open",
|
|
126
|
+
() => {
|
|
127
|
+
socket.removeEventListener("close", unopened);
|
|
128
|
+
// A scope that closed meanwhile keeps no connection.
|
|
129
|
+
if (state._tag === "Closed") {
|
|
130
|
+
socket.close(1000);
|
|
131
|
+
|
|
132
|
+
return resume(Effect.fail(failure("the capture connection is closed")));
|
|
133
|
+
}
|
|
134
|
+
state = { _tag: "Open", open };
|
|
135
|
+
socket.addEventListener("close", () =>
|
|
136
|
+
lose(open, failure("the capture connection was lost")),
|
|
137
|
+
);
|
|
138
|
+
resume(Effect.succeed(open));
|
|
139
|
+
},
|
|
140
|
+
{ once: true },
|
|
141
|
+
);
|
|
142
|
+
socket.addEventListener("message", ({ data }: MessageEvent<unknown>) => receive(open, data));
|
|
143
|
+
|
|
144
|
+
return Effect.sync(() => socket.close(1000));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const current = opening.withPermits(1)(
|
|
148
|
+
Effect.suspend(() =>
|
|
149
|
+
state._tag === "Open"
|
|
150
|
+
? Effect.succeed(state.open)
|
|
151
|
+
: state._tag === "Closed"
|
|
152
|
+
? Effect.fail(failure("the capture connection is closed"))
|
|
153
|
+
: connect,
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const replied = (reply: () => Promise<unknown>) =>
|
|
158
|
+
Effect.tryPromise({
|
|
159
|
+
try: reply,
|
|
160
|
+
catch: (cause) =>
|
|
161
|
+
Schema.is(BrowserError)(cause) ? cause : failure("the capture connection failed"),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const attach: CaptureSource["attach"] = (target, listener) =>
|
|
165
|
+
Effect.gen(function* () {
|
|
166
|
+
const open = yield* current;
|
|
167
|
+
|
|
168
|
+
const { sessionId } = yield* replied(() =>
|
|
169
|
+
send(open, "Target.attachToTarget", { targetId: target, flatten: true }),
|
|
170
|
+
).pipe(
|
|
171
|
+
Effect.flatMap((result) =>
|
|
172
|
+
Schema.decodeUnknownEffect(Session)(result).pipe(
|
|
173
|
+
Effect.mapError(() => failure("the capture connection's attach had no session")),
|
|
174
|
+
),
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
open.sessions.set(sessionId, listener);
|
|
179
|
+
// Detached as the page's scope closes; a connection that closes detaches every session.
|
|
180
|
+
yield* Effect.addFinalizer(() =>
|
|
181
|
+
Effect.sync(() => {
|
|
182
|
+
if (open.sessions.delete(sessionId))
|
|
183
|
+
void send(open, "Target.detachFromTarget", { sessionId }, undefined).catch(
|
|
184
|
+
() => undefined,
|
|
185
|
+
);
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
return (method, params) => send(open, method, params, sessionId);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Closing the scope closes the connection and waits, bounded, until it has closed.
|
|
193
|
+
yield* Effect.addFinalizer(() =>
|
|
194
|
+
Effect.suspend(() => {
|
|
195
|
+
const previous = state;
|
|
196
|
+
|
|
197
|
+
state = { _tag: "Closed" };
|
|
198
|
+
if (previous._tag !== "Open") return Effect.void;
|
|
199
|
+
const { socket, replies, sessions } = previous.open;
|
|
200
|
+
|
|
201
|
+
for (const reply of replies.values()) reply.reject(failure("the capture connection closed"));
|
|
202
|
+
replies.clear();
|
|
203
|
+
sessions.clear();
|
|
204
|
+
|
|
205
|
+
return Effect.callback<void>((resume) => {
|
|
206
|
+
if (socket.readyState === WebSocket.CLOSED) return resume(Effect.void);
|
|
207
|
+
socket.addEventListener("close", () => resume(Effect.void), { once: true });
|
|
208
|
+
socket.close(1000);
|
|
209
|
+
}).pipe(Effect.timeoutOrElse({ duration: closing, orElse: () => Effect.void }));
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
return { attach } satisfies CaptureSource;
|
|
214
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this package relies on the Browserbase API to do, as named checks over
|
|
3
|
+
* `BrowserbaseClient`, each an Effect with no test runner around it. A package test runs them
|
|
4
|
+
* against `TestBrowserbase`, which keeps the fake to them; run against Browserbase itself, they
|
|
5
|
+
* show whether the fake is still faithful, at the cost of a few one-minute sessions. Checks that
|
|
6
|
+
* move the test clock run only against the fake.
|
|
7
|
+
*
|
|
8
|
+
* @category testing
|
|
9
|
+
* @since 0.3.0
|
|
10
|
+
*/
|
|
11
|
+
import { Clock, Effect, Schedule, Schema } from "effect";
|
|
12
|
+
import { TestClock } from "effect/testing";
|
|
13
|
+
|
|
14
|
+
import { BrowserbaseClient, type Session } from "../BrowserbaseClient.ts";
|
|
15
|
+
import type { BrowserbaseError } from "../BrowserbaseError.ts";
|
|
16
|
+
|
|
17
|
+
/** A check found the API behaving otherwise. */
|
|
18
|
+
export class Broken extends Schema.TaggedError<Broken>()("Broken", {
|
|
19
|
+
check: Schema.String,
|
|
20
|
+
detail: Schema.String,
|
|
21
|
+
}) {
|
|
22
|
+
override get message() {
|
|
23
|
+
return `${this.check}: ${this.detail}`;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Check {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
/** It moves the test clock, so only the fake can run it. */
|
|
30
|
+
readonly clock: boolean;
|
|
31
|
+
readonly run: Effect.Effect<void, Broken | BrowserbaseError, BrowserbaseClient>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const check = (
|
|
35
|
+
name: string,
|
|
36
|
+
run: (
|
|
37
|
+
expect: (holds: boolean, detail: string) => Effect.Effect<void, Broken>,
|
|
38
|
+
) => Effect.Effect<void, Broken | BrowserbaseError, BrowserbaseClient>,
|
|
39
|
+
clock = false,
|
|
40
|
+
): Check => ({
|
|
41
|
+
name,
|
|
42
|
+
clock,
|
|
43
|
+
run: run((holds, detail) =>
|
|
44
|
+
holds ? Effect.void : Effect.fail(new Broken({ check: name, detail })),
|
|
45
|
+
).pipe(Effect.withSpan("BrowserbaseContract.check", { attributes: { check: name } })),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const running = (session: Session) => session.status === "PENDING" || session.status === "RUNNING";
|
|
49
|
+
|
|
50
|
+
/** What the call failed with, or `nothing` when it succeeded. */
|
|
51
|
+
const failure = <A, R>(call: Effect.Effect<A, BrowserbaseError, R>) =>
|
|
52
|
+
Effect.match(call, { onFailure: (error) => error.reason._tag, onSuccess: () => "nothing" });
|
|
53
|
+
|
|
54
|
+
/** Read the session until it has ended, for at most 10 seconds. */
|
|
55
|
+
const ended = (client: BrowserbaseClient["Service"], id: string) =>
|
|
56
|
+
client.getSession(id).pipe(
|
|
57
|
+
Effect.repeat({
|
|
58
|
+
until: (session) => !running(session),
|
|
59
|
+
schedule: Schedule.spaced("1 second").pipe(Schedule.upTo({ times: 10 })),
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
export const checks: ReadonlyArray<Check> = [
|
|
64
|
+
check("a new session runs, and ends once released", (expect) =>
|
|
65
|
+
Effect.gen(function* () {
|
|
66
|
+
const client = yield* BrowserbaseClient;
|
|
67
|
+
const session = yield* client.createSession({ timeout: 60 });
|
|
68
|
+
|
|
69
|
+
yield* expect(running(session), `a new session is ${session.status}`);
|
|
70
|
+
yield* client.releaseSession(session.id);
|
|
71
|
+
const after = yield* ended(client, session.id);
|
|
72
|
+
|
|
73
|
+
yield* expect(after.status === "COMPLETED", `a released session is ${after.status}`);
|
|
74
|
+
// Releasing a session that has ended succeeds and changes nothing.
|
|
75
|
+
yield* client.releaseSession(session.id);
|
|
76
|
+
const again = yield* client.getSession(session.id);
|
|
77
|
+
|
|
78
|
+
yield* expect(again.status === "COMPLETED", `released twice, it is ${again.status}`);
|
|
79
|
+
}),
|
|
80
|
+
),
|
|
81
|
+
check("a well-formed id no session has is not found", (expect) =>
|
|
82
|
+
Effect.gen(function* () {
|
|
83
|
+
const client = yield* BrowserbaseClient;
|
|
84
|
+
const read = yield* failure(client.getSession("00000000-0000-4000-8000-000000000000"));
|
|
85
|
+
|
|
86
|
+
yield* expect(read === "NotFound", `reading it failed with ${read}`);
|
|
87
|
+
}),
|
|
88
|
+
),
|
|
89
|
+
check("a session id of another shape is refused as invalid", (expect) =>
|
|
90
|
+
Effect.gen(function* () {
|
|
91
|
+
const client = yield* BrowserbaseClient;
|
|
92
|
+
const read = yield* failure(client.getSession("not-a-session-of-this-project"));
|
|
93
|
+
|
|
94
|
+
yield* expect(read === "InvalidRequest", `reading it failed with ${read}`);
|
|
95
|
+
}),
|
|
96
|
+
),
|
|
97
|
+
check("sessions are found by status and user metadata", (expect) =>
|
|
98
|
+
Effect.gen(function* () {
|
|
99
|
+
const client = yield* BrowserbaseClient;
|
|
100
|
+
const label = `contract-${yield* Clock.currentTimeMillis}`;
|
|
101
|
+
const session = yield* client.createSession({ timeout: 60, userMetadata: { label } });
|
|
102
|
+
const query = (value: string) => `user_metadata['label']:'${value}'`;
|
|
103
|
+
|
|
104
|
+
// A new session is PENDING or RUNNING, and may change between the two queries, so the
|
|
105
|
+
// package looks under both statuses; a check of one alone would fail a compliant API.
|
|
106
|
+
const running = (value: string) =>
|
|
107
|
+
Effect.map(
|
|
108
|
+
Effect.forEach(["PENDING", "RUNNING"], (status) =>
|
|
109
|
+
client.listSessions({ status, query: query(value) }),
|
|
110
|
+
),
|
|
111
|
+
(found) => new Set(found.flat().map(({ id }) => id)),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const found = yield* running(label);
|
|
115
|
+
const other = yield* running(`${label}-x`);
|
|
116
|
+
|
|
117
|
+
yield* client.releaseSession(session.id);
|
|
118
|
+
yield* ended(client, session.id);
|
|
119
|
+
const gone = yield* running(label);
|
|
120
|
+
// With no status, a search finds sessions in every state, as a lost create's search needs.
|
|
121
|
+
const any = yield* client.listSessions({ query: query(label) });
|
|
122
|
+
|
|
123
|
+
yield* expect(
|
|
124
|
+
found.size === 1 && found.has(session.id),
|
|
125
|
+
`${found.size} unended sessions had its label`,
|
|
126
|
+
);
|
|
127
|
+
yield* expect(other.size === 0, `${other.size} sessions had another label`);
|
|
128
|
+
yield* expect(gone.size === 0, `${gone.size} still ran once it had ended`);
|
|
129
|
+
yield* expect(
|
|
130
|
+
any.some(({ id, status }) => id === session.id && status === "COMPLETED"),
|
|
131
|
+
`with no status, a search found ${any.length} sessions, not it ended`,
|
|
132
|
+
);
|
|
133
|
+
}),
|
|
134
|
+
),
|
|
135
|
+
check("a context id of another shape is refused as invalid", (expect) =>
|
|
136
|
+
Effect.gen(function* () {
|
|
137
|
+
const client = yield* BrowserbaseClient;
|
|
138
|
+
const read = yield* failure(client.getContext("not-a-context-of-this-project"));
|
|
139
|
+
|
|
140
|
+
yield* expect(read === "InvalidRequest", `reading it failed with ${read}`);
|
|
141
|
+
}),
|
|
142
|
+
),
|
|
143
|
+
check("a stored context reads back, and is gone once deleted", (expect) =>
|
|
144
|
+
Effect.gen(function* () {
|
|
145
|
+
const client = yield* BrowserbaseClient;
|
|
146
|
+
const created = yield* client.createContext();
|
|
147
|
+
const read = yield* client.getContext(created.id);
|
|
148
|
+
|
|
149
|
+
yield* client.deleteContext(created.id);
|
|
150
|
+
const gone = yield* failure(client.getContext(created.id));
|
|
151
|
+
|
|
152
|
+
yield* expect(read.id === created.id, `it read back as ${read.id}`);
|
|
153
|
+
yield* expect(gone === "NotFound", `once deleted, reading it failed with ${gone}`);
|
|
154
|
+
}),
|
|
155
|
+
),
|
|
156
|
+
check(
|
|
157
|
+
"a session ends at its timeout",
|
|
158
|
+
(expect) =>
|
|
159
|
+
Effect.gen(function* () {
|
|
160
|
+
const client = yield* BrowserbaseClient;
|
|
161
|
+
const session = yield* client.createSession({ timeout: 60 });
|
|
162
|
+
|
|
163
|
+
yield* TestClock.adjust("61 seconds");
|
|
164
|
+
const after = yield* client.getSession(session.id);
|
|
165
|
+
|
|
166
|
+
yield* expect(after.status === "TIMED_OUT", `after its timeout it is ${after.status}`);
|
|
167
|
+
}),
|
|
168
|
+
true,
|
|
169
|
+
),
|
|
170
|
+
];
|