Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6671e27
feat(extension): add network recording for BrowserStack LT integration
nafees87n May 28, 2026
182280b
refactor(extension): emit network recording events as HAR 1.2 entries
nafees87n May 29, 2026
77cdec9
feat(extension): stream network recording entries to LTS over a port
nafees87n May 29, 2026
09bcf56
feat(extension): stop returns summary; { action, payload } external e…
nafees87n May 29, 2026
ed05a92
feat(extension): Firefox sidebar for network recording panel
nafees87n May 29, 2026
2a2bc02
feat(extension): keep the service worker alive during a network recor…
nafees87n May 29, 2026
56f3668
fix(extension): address network-recording code review findings
nafees87n May 29, 2026
5d817db
feat(extension): add getNetworkRecordingSummary for stream consumers
nafees87n Jun 1, 2026
23003d8
feat(extension): network panel UX — host display, scroll, chip wrap
nafees87n Jun 1, 2026
0c02db8
refactor(extension): finalize stop/complete/summary contract
nafees87n Jun 1, 2026
c47400f
Merge branch 'master' into feat/network-recording-har
nafees87n Jun 1, 2026
f42ef06
refactor(extension): drop unreachable fetch resourceType branch
nafees87n Jun 1, 2026
b1c5865
chore(extension): log received network entries to console in test har…
nafees87n Jun 2, 2026
e3138ec
feat(extension): network-recording lifecycle — focus-return, disconne…
nafees87n Jun 2, 2026
0197339
feat(extension): guard network recording on extension-enabled state
nafees87n Jun 3, 2026
21c86fe
refactor(extension): drop chrome.alarms from network recording
nafees87n Jun 3, 2026
e5f2bcc
fix(extension): decouple network-recording panel open from tab creation
nafees87n Jun 3, 2026
53d9d75
fix(extension): restore eager side-panel open on network recording start
nafees87n Jun 3, 2026
a1cae4e
feat(extension): reject network recording start when extension is dis…
nafees87n Jun 3, 2026
b87bd7b
fix(extension): catch first-ever disable toggle for network recording
nafees87n Jun 3, 2026
8bce6fe
fix(extension): don't route user away on extension-disabled stop
nafees87n Jun 3, 2026
db11d14
refactor(extension): route user back only on user-initiated stop
nafees87n Jun 3, 2026
8a95ecc
feat(extension): Network Interceptor v2 — request/response bodies + h…
nafees87n Jun 3, 2026
eacad9c
fix(extension): network recording — dedupe SDK re-injection + determi…
nafees87n Jun 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions browser-extension/common/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,36 @@ export default [
},
plugins: [...commonPlugins, nodeResolve()],
},
{
...commonConfig,
input: "src/sidepanel/network-recording/index.tsx",
output: {
file: `${OUTPUT_DIR}/sidepanel/network-recording/index.js`,
format: "iife",
},
context: "window",
plugins: [
copy({
targets: [
{
src: "src/sidepanel/network-recording/index.html",
dest: `${OUTPUT_DIR}/sidepanel/network-recording`,
},
],
}),
nodeResolve(),
replace({
preventAssignment: true,
"process.env.NODE_ENV": JSON.stringify("production"),
}),
...commonPlugins,
commonjs(),
postcss({
extract: true,
}),
svgr(),
],
},
{
...commonConfig,
input: "src/custom-elements/index.ts",
Expand Down
11 changes: 11 additions & 0 deletions browser-extension/common/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,18 @@ export const EXTENSION_MESSAGES = {
DESKTOP_APP_CONNECTION_STATUS_UPDATED: "desktopAppConnectionStatusUpdated",
IS_SESSION_REPLAY_ENABLED: "isSessionReplayEnabled",
TRIGGER_OPEN_CURL_MODAL: "triggerOpenCurlModal",
STOP_NETWORK_RECORDING: "stopNetworkRecording",
GET_NETWORK_RECORDING_STATE: "getNetworkRecordingState",
// v2 body capture: SW → client content script → page script (networkBodyRecorder) start/stop.
START_NETWORK_BODY_CAPTURE: "startNetworkBodyCapture",
STOP_NETWORK_BODY_CAPTURE: "stopNetworkBodyCapture",
};

export const EXTENSION_EXTERNAL_MESSAGES = {
GET_EXTENSION_METADATA: "getExtensionMetadata",
START_NETWORK_RECORDING: "startNetworkRecording",
STOP_NETWORK_RECORDING: "stopNetworkRecording",
GET_NETWORK_RECORDING_SUMMARY: "getNetworkRecordingSummary",
};

export const CLIENT_MESSAGES = {
Expand All @@ -72,6 +80,9 @@ export const CLIENT_MESSAGES = {
NOTIFY_RECORD_UPDATED: "notifyRecordUpdated",
NOTIFY_EXTENSION_STATUS_UPDATED: "notifyExtensionStatusUpdated",
OPEN_CURL_IMPORT_MODAL: "openCurlImportModal",
NETWORK_EVENT_CAPTURED: "networkEventCaptured",
NETWORK_RECORDING_ENDED: "networkRecordingEnded",
NETWORK_BODY_CAPTURED: "networkBodyCaptured",
};

export const STORAGE_TYPE = "local";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { NetworkEntry } from "./types";
import NetworkEventRow from "./components/NetworkEventRow";
import FilterBar from "./components/FilterBar";

// Maps the HAR _resourceType (DevTools enum) to the short label shown in the list.
const RESOURCE_TYPE_DISPLAY: Record<string, string> = {
document: "document",
stylesheet: "css",
script: "js",
image: "img",
font: "font",
media: "media",
websocket: "ws",
xhr: "xhr",
other: "other",
};

const formatTime = (ms: number): string => {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
};

const formatSize = (bytes: number | undefined): string => {
if (bytes === undefined || bytes < 0) return "—"; // -1 = size unknown (HAR sentinel)
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};

// Why a recording ended — mirrors the StopReason union in the service worker.
type StopReason = "user" | "max-duration" | "connection-lost" | "tab-closed" | "extension-disabled";

// Banner shown for SW-initiated stops. `user` and `tab-closed` show no banner (the user knows /
// the panel is gone), so they're absent from this map.
const STOP_BANNERS: Partial<Record<StopReason, { icon: string; text: string; variant: string }>> = {
"max-duration": {
icon: "⏱",
text: "Recording stopped — time limit reached",
variant: "warning",
},
"connection-lost": {
icon: "⚠",
text: "Connection to Load Testing lost — recording stopped",
variant: "error",
},
"extension-disabled": {
icon: "⚠",
text: "Requestly was disabled — recording stopped",
variant: "error",
},
};

const NetworkRecordingPanel: React.FC = () => {
const [entries, setEntries] = useState<NetworkEntry[]>([]);
const [filter, setFilter] = useState({ text: "", method: "ALL" });
const [recordingStartTime, setRecordingStartTime] = useState<number>(Date.now());
const [isRecording, setIsRecording] = useState(true);
const [stopReason, setStopReason] = useState<StopReason | null>(null);
const [elapsedTime, setElapsedTime] = useState(0);
const [targetUrl, setTargetUrl] = useState("");
const currentTabIdRef = useRef<number | null>(null);
const listRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const init = async () => {
// Bind to the recording this panel is for. On Chrome/Edge the SW opens the per-tab panel
// with ?tabId=<targetTabId>, so we read it from our own URL — deterministic even when
// multiple tabs are recording at once. Firefox has a single global sidebar (no per-tab URL),
// so fall back to the active tab; the sidebar then shows whichever recorded tab is active.
const tabIdParam = new URLSearchParams(window.location.search).get("tabId");
let tabId = tabIdParam ? Number(tabIdParam) : null;
if (tabId === null) {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
tabId = tab?.id ?? null;
}
if (tabId === null) return;
currentTabIdRef.current = tabId;

chrome.runtime.sendMessage({ action: "getNetworkRecordingState", tabId }, (response) => {
if (response?.active) {
setEntries(response.entries || []);
setRecordingStartTime(response.startTime);
setIsRecording(true);
// Header host comes from the recording's own URL (the SW's source of truth), not the
// active tab — important on Firefox where the active tab may not be the recorded one.
try {
setTargetUrl(new URL(response.url).hostname);
} catch {
setTargetUrl(response.url || "");
}
}
});
};

init();

const listener = (message: any) => {
if (message.tabId !== currentTabIdRef.current) return;
if (message.action === "networkEventCaptured") {
setEntries((prev) => [...prev, message.entry]);
} else if (message.action === "networkRecordingEnded") {
setIsRecording(false);
setStopReason(message.reason ?? "user");
}
};

chrome.runtime.onMessage.addListener(listener);
return () => chrome.runtime.onMessage.removeListener(listener);
}, []);

useEffect(() => {
if (!isRecording) return undefined;

const interval = setInterval(() => {
setElapsedTime(Date.now() - recordingStartTime);
}, 1000);

return () => clearInterval(interval);
}, [isRecording, recordingStartTime]);

// Auto-scroll to the newest entry, but only while the user is pinned to the bottom.
// Once they scroll up, stop yanking them back down until they return to the bottom.
const stickToBottomRef = useRef(true);

const handleListScroll = useCallback(() => {
const el = listRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
stickToBottomRef.current = distanceFromBottom <= 24; // within ~1 row of the bottom
}, []);

useEffect(() => {
if (listRef.current && stickToBottomRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, [entries.length]);

const handleStop = useCallback(() => {
chrome.runtime.sendMessage({
action: "stopNetworkRecording",
targetTabId: currentTabIdRef.current,
});
setIsRecording(false);
setStopReason("user");
}, []);

const filteredEntries = useMemo(() => {
return entries.filter((entry) => {
if (filter.method !== "ALL" && entry.request.method !== filter.method) return false;
if (filter.text && !entry.request.url.toLowerCase().includes(filter.text.toLowerCase())) return false;
return true;
});
}, [entries, filter]);

const counts = useMemo(() => {
const total = filteredEntries.length;
const xhr = filteredEntries.filter((e) => e._resourceType === "xhr").length;
const docs = filteredEntries.filter((e) => e._resourceType === "document").length;
const staticCount = filteredEntries.filter((e) =>
["script", "stylesheet", "image", "font"].includes(e._resourceType as string)
).length;
return { total, xhr, docs, static: staticCount };
}, [filteredEntries]);

return (
<div className="network-panel">
<div className="panel-header">
<div className="header-top">
<div className="recording-status">
{isRecording && <span className="recording-dot" />}
<span className="recording-label">{isRecording ? "Recording" : "Stopped"}</span>
<span className="recording-time">{formatTime(elapsedTime)}</span>
</div>
{isRecording && (
<button className="stop-btn" onClick={handleStop}>
<span className="stop-icon" />
Stop
</button>
)}
</div>
{targetUrl && <div className="target-url">{targetUrl}</div>}
</div>

{!isRecording && stopReason && STOP_BANNERS[stopReason] && (
<div className={`end-banner end-banner--${STOP_BANNERS[stopReason].variant}`}>
<span className="end-banner-icon">{STOP_BANNERS[stopReason].icon}</span>
<span className="end-banner-text">{STOP_BANNERS[stopReason].text}</span>
</div>
)}

<div className="summary-counters">
<div className="counter">
<span className="counter-value">{counts.total}</span>
<span className="counter-label">Total</span>
</div>
<div className="counter">
<span className="counter-value">{counts.xhr}</span>
<span className="counter-label">XHR</span>
</div>
<div className="counter">
<span className="counter-value">{counts.docs}</span>
<span className="counter-label">Docs</span>
</div>
<div className="counter">
<span className="counter-value">{counts.static}</span>
<span className="counter-label">Static</span>
</div>
</div>

<FilterBar filter={filter} onFilterChange={setFilter} />

<div className="request-list" ref={listRef} onScroll={handleListScroll}>
{filteredEntries.map((entry) => (
<NetworkEventRow
key={entry._request_id as string}
entry={entry}
typeDisplay={
RESOURCE_TYPE_DISPLAY[entry._resourceType as string] || (entry._resourceType as string) || "other"
}
formatSize={formatSize}
/>
))}
{filteredEntries.length === 0 && (
<div className="empty-state">
{entries.length === 0 ? "Waiting for network requests..." : "No requests match the current filter"}
</div>
)}
</div>

<div className="panel-footer">
<span className="version">v{chrome.runtime.getManifest().version}</span>
</div>
</div>
);
};

export default NetworkRecordingPanel;
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React from "react";

const METHODS = ["ALL", "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"] as const;

interface FilterBarProps {
filter: { text: string; method: string };
onFilterChange: (filter: { text: string; method: string }) => void;
}

const FilterBar: React.FC<FilterBarProps> = ({ filter, onFilterChange }) => {
return (
<div className="filter-bar">
<div className="filter-input-wrapper">
<svg
className="search-icon"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="#9e9e9e"
strokeWidth="2"
>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
<input
type="text"
className="filter-input"
placeholder="Filter requests..."
value={filter.text}
onChange={(e) => onFilterChange({ ...filter, text: e.target.value })}
/>
{filter.text && (
<button className="filter-clear" onClick={() => onFilterChange({ ...filter, text: "" })}>
×
</button>
)}
</div>
<div className="method-chips">
{METHODS.map((method) => (
<button
key={method}
className={`method-chip ${filter.method === method ? "method-chip--active" : ""}`}
onClick={() => onFilterChange({ ...filter, method })}
>
{method === "ALL" ? "All" : method}
</button>
))}
</div>
</div>
);
};

export default FilterBar;
Loading