Skip to content

Commit cb3d902

Browse files
authored
(feat/interact) Session Replay (firecrawl#3964)
* Nick: * Nick:
1 parent 8671d15 commit cb3d902

8 files changed

Lines changed: 512 additions & 1 deletion

File tree

apps/api/requests/v2/browser.requests.http

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,23 @@ content-type: application/json
8181
"language": "python"
8282
}
8383

84+
### Get Session Replay metadata — works after the session is destroyed
85+
GET {{baseUrl}}/v2/interact/{{sessionId}}/replay HTTP/1.1
86+
Authorization: Bearer {{$dotenv TEST_API_KEY}}
87+
88+
### Get Session Replay HLS playlist for tab 0
89+
GET {{baseUrl}}/v2/interact/{{sessionId}}/replay/0 HTTP/1.1
90+
Authorization: Bearer {{$dotenv TEST_API_KEY}}
91+
92+
### Create Browser Session without recording
93+
POST {{baseUrl}}/v2/browser HTTP/1.1
94+
Authorization: Bearer {{$dotenv TEST_API_KEY}}
95+
content-type: application/json
96+
97+
{
98+
"recordSession": false
99+
}
100+
84101
### Delete Browser Session — replace sessionId with actual id
85102
DELETE {{baseUrl}}/v2/browser/{{sessionId}} HTTP/1.1
86103
Authorization: Bearer {{$dotenv TEST_API_KEY}}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import crypto from "crypto";
2+
import { config } from "../../../config";
3+
import {
4+
ALLOW_TEST_SUITE_WEBSITE,
5+
HAS_FIRE_ENGINE,
6+
TEST_PRODUCTION,
7+
TEST_SELF_HOST,
8+
TEST_SUITE_WEBSITE,
9+
itIf,
10+
} from "../lib";
11+
import {
12+
Identity,
13+
idmux,
14+
browserCreateRaw,
15+
browserExecuteRaw,
16+
browserDeleteRaw,
17+
browserReplayRaw,
18+
browserReplayPageRaw,
19+
scrapeTimeout,
20+
} from "./lib";
21+
22+
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
23+
24+
describe("Interact session replay", () => {
25+
let identity: Identity;
26+
let otherIdentity: Identity;
27+
28+
beforeAll(async () => {
29+
identity = await idmux({
30+
name: "browser-replay",
31+
concurrency: 20,
32+
credits: 1_000_000,
33+
});
34+
otherIdentity = await idmux({
35+
name: "browser-replay-other",
36+
concurrency: 10,
37+
credits: 1_000_000,
38+
});
39+
}, 10000 + scrapeTimeout);
40+
41+
const canRunReplayHappyPath =
42+
ALLOW_TEST_SUITE_WEBSITE &&
43+
!!config.BROWSER_SERVICE_URL &&
44+
(TEST_PRODUCTION || HAS_FIRE_ENGINE);
45+
46+
itIf(canRunReplayHappyPath)(
47+
"records a session and serves replay metadata + HLS playlist after destroy",
48+
async () => {
49+
let sessionId: string | null = null;
50+
51+
try {
52+
const createResponse = await browserCreateRaw(
53+
{ ttl: 120, activityTtl: 120 },
54+
identity,
55+
);
56+
expect(createResponse.statusCode).toBe(200);
57+
expect(createResponse.body.success).toBe(true);
58+
sessionId = createResponse.body.id as string;
59+
60+
// Generate on-screen activity so the screencast has frames to record.
61+
const executeResponse = await browserExecuteRaw(
62+
sessionId,
63+
{
64+
language: "node",
65+
timeout: 60,
66+
code: `
67+
await page.goto("${TEST_SUITE_WEBSITE}?testId=${crypto.randomUUID()}");
68+
console.log("navigated");
69+
`,
70+
},
71+
identity,
72+
);
73+
expect(executeResponse.statusCode).toBe(200);
74+
expect(executeResponse.body.success).toBe(true);
75+
76+
// Wait past a segment boundary (10s) so at least one segment uploads.
77+
await sleep(15_000);
78+
} finally {
79+
if (sessionId) {
80+
await browserDeleteRaw(sessionId, identity);
81+
}
82+
}
83+
84+
// Replay must be available after the session is destroyed.
85+
let replayResponse = await browserReplayRaw(sessionId!, identity);
86+
for (let i = 0; i < 10 && replayResponse.statusCode === 404; i++) {
87+
await sleep(2000);
88+
replayResponse = await browserReplayRaw(sessionId!, identity);
89+
}
90+
91+
expect(replayResponse.statusCode).toBe(200);
92+
expect(replayResponse.body.success).toBe(true);
93+
expect(replayResponse.body.pageCount).toBeGreaterThan(0);
94+
expect(Array.isArray(replayResponse.body.pages)).toBe(true);
95+
96+
const page = replayResponse.body.pages[0];
97+
expect(typeof page.pageId).toBe("string");
98+
expect(page.url).toBe(
99+
`/v2/interact/${sessionId}/replay/${page.pageId}`,
100+
);
101+
// pageUrl is the recorded page URL — should reflect where we navigated.
102+
expect(typeof page.pageUrl).toBe("string");
103+
expect(page.pageUrl).toContain(TEST_SUITE_WEBSITE);
104+
expect(page.endTimeMs).toBeGreaterThan(page.startTimeMs);
105+
106+
const playlistResponse = await browserReplayPageRaw(
107+
sessionId!,
108+
page.pageId,
109+
identity,
110+
);
111+
expect(playlistResponse.statusCode).toBe(200);
112+
expect(playlistResponse.headers["content-type"]).toContain(
113+
"application/vnd.apple.mpegurl",
114+
);
115+
const playlist = playlistResponse.text as string;
116+
expect(playlist).toContain("#EXTM3U");
117+
expect(playlist).toContain("#EXT-X-ENDLIST");
118+
expect(playlist).toContain("https://");
119+
},
120+
scrapeTimeout + 60_000,
121+
);
122+
123+
itIf(!TEST_SELF_HOST)(
124+
"returns 404 when the session does not exist",
125+
async () => {
126+
const response = await browserReplayRaw(crypto.randomUUID(), identity);
127+
128+
expect(response.statusCode).toBe(404);
129+
expect(response.body.success).toBe(false);
130+
expect(response.body.error).toBe("Browser session not found.");
131+
},
132+
);
133+
134+
itIf(!TEST_SELF_HOST)(
135+
"returns 400 for an invalid pageId",
136+
async () => {
137+
const response = await browserReplayPageRaw(
138+
crypto.randomUUID(),
139+
"not-a-page",
140+
identity,
141+
);
142+
143+
expect(response.statusCode).toBe(400);
144+
expect(response.body.success).toBe(false);
145+
expect(response.body.error).toBe("Invalid pageId.");
146+
},
147+
);
148+
149+
itIf(canRunReplayHappyPath && !!config.IDMUX_URL)(
150+
"returns 403 when the session belongs to another team",
151+
async () => {
152+
if (identity.teamId === otherIdentity.teamId) {
153+
return;
154+
}
155+
156+
let sessionId: string | null = null;
157+
try {
158+
const createResponse = await browserCreateRaw(
159+
{ ttl: 60, activityTtl: 60 },
160+
identity,
161+
);
162+
expect(createResponse.statusCode).toBe(200);
163+
sessionId = createResponse.body.id as string;
164+
165+
const response = await browserReplayRaw(sessionId, otherIdentity);
166+
expect(response.statusCode).toBe(403);
167+
expect(response.body.success).toBe(false);
168+
expect(response.body.error).toBe("Forbidden.");
169+
} finally {
170+
if (sessionId) {
171+
await browserDeleteRaw(sessionId, identity);
172+
}
173+
}
174+
},
175+
scrapeTimeout,
176+
);
177+
});

apps/api/src/__tests__/snips/v2/lib.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,71 @@ export async function scrapeStopInteractiveBrowserRaw(
340340
.send();
341341
}
342342

343+
// =========================================
344+
// Interact (standalone browser sessions)
345+
// =========================================
346+
347+
export async function browserCreateRaw(
348+
body: {
349+
ttl?: number;
350+
activityTtl?: number;
351+
recordSession?: boolean;
352+
},
353+
identity: Identity,
354+
) {
355+
return await request(TEST_API_URL)
356+
.post("/v2/interact")
357+
.set("Authorization", `Bearer ${identity.apiKey}`)
358+
.set("Content-Type", "application/json")
359+
.send(body);
360+
}
361+
362+
export async function browserExecuteRaw(
363+
sessionId: string,
364+
body: {
365+
code: string;
366+
language?: "python" | "node" | "bash";
367+
timeout?: number;
368+
},
369+
identity: Identity,
370+
) {
371+
return await request(TEST_API_URL)
372+
.post("/v2/interact/" + encodeURIComponent(sessionId) + "/execute")
373+
.set("Authorization", `Bearer ${identity.apiKey}`)
374+
.set("Content-Type", "application/json")
375+
.send(body);
376+
}
377+
378+
export async function browserDeleteRaw(sessionId: string, identity: Identity) {
379+
return await request(TEST_API_URL)
380+
.delete("/v2/interact/" + encodeURIComponent(sessionId))
381+
.set("Authorization", `Bearer ${identity.apiKey}`)
382+
.send();
383+
}
384+
385+
export async function browserReplayRaw(sessionId: string, identity: Identity) {
386+
return await request(TEST_API_URL)
387+
.get("/v2/interact/" + encodeURIComponent(sessionId) + "/replay")
388+
.set("Authorization", `Bearer ${identity.apiKey}`)
389+
.send();
390+
}
391+
392+
export async function browserReplayPageRaw(
393+
sessionId: string,
394+
pageId: string,
395+
identity: Identity,
396+
) {
397+
return await request(TEST_API_URL)
398+
.get(
399+
"/v2/interact/" +
400+
encodeURIComponent(sessionId) +
401+
"/replay/" +
402+
encodeURIComponent(pageId),
403+
)
404+
.set("Authorization", `Bearer ${identity.apiKey}`)
405+
.send();
406+
}
407+
343408
// =========================================
344409
// Crawl API
345410
// =========================================

0 commit comments

Comments
 (0)