Skip to content

Commit fa84dc0

Browse files
committed
feat: Implement session replay tool.
1 parent 7e40085 commit fa84dc0

File tree

13 files changed

+1009
-49
lines changed

13 files changed

+1009
-49
lines changed

forge.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const config: ForgeConfig = {
4040
],
4141
},
4242
devContentSecurityPolicy:
43-
"default-src 'self' 'unsafe-inline' static:; script-src 'self' 'unsafe-eval' 'unsafe-inline';",
43+
"default-src 'self' 'unsafe-inline' static:;img-src 'self' data: static:;script-src 'self' 'unsafe-eval' 'unsafe-inline';",
4444
}),
4545
// Fuses are used to enable/disable various Electron functionality
4646
// at package time, before code signing the application

src/channelHandlers/browserstack-api.ts

Lines changed: 172 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import CONFIG from "../constants/config"
33

44
const BASE_URL = 'https://api.browserstack.com'
55

6-
const getAuth = (username?:string,accessKey?:string) => {
6+
const getAuth = (username?: string, accessKey?: string) => {
77
return `Basic ${Buffer.from(`${username || CONFIG.adminUsername}:${accessKey || CONFIG.adminAccessKey}`).toString('base64')}`
88
}
99

@@ -27,12 +27,177 @@ export const getAutomateSessionDetails: BrowserStackAPI['getAutomateSessionDetai
2727
return sessionDetailsJSON
2828
}
2929

30-
export const getParsedAutomateTextLogs = async (session:AutomateSessionResponse) => {
30+
export const getParsedAutomateTextLogs = async (session: AutomateSessionResponse) => {
3131
const logs = await download(session.automation_session.logs);
32-
const result = parseAutomateTextLogs(logs.split('\n'))
33-
return result
34-
}
32+
const lines = logs.split('\n');
33+
34+
const timestampRegex = /^\d{4}-\d{2}-\d{2} \d{1,2}:\d{1,2}:\d{1,2}:\d{1,3}/;
35+
36+
const entries: string[] = [];
37+
38+
for (const line of lines) {
39+
if (timestampRegex.test(line)) {
40+
// New log entry → push as a new entry
41+
entries.push(line);
42+
} else if (entries.length > 0) {
43+
// Continuation of previous entry → append
44+
entries[entries.length - 1] += '\n' + line;
45+
} else {
46+
// Edge case: first line doesn't start with timestamp
47+
entries.push(line);
48+
}
49+
}
50+
51+
console.log(entries)
52+
53+
return parseAutomateTextLogs(entries);
54+
};
55+
56+
const sendRequest = async (method: string, url: string, body: any = {}, auth: string) => {
57+
delete body.fetchRawLogs;
58+
59+
// BrowserStack WebDriver quirk: convert "text" → "value" array for sendKeys
60+
// if (util.getCommandName?.(url) === 'sendKeys' && !body['value'] && body['text']) {
61+
// body['value'] = body['text'].split('');
62+
// }
63+
64+
const headers = {
65+
'Content-Type': 'application/json; charset=utf-8',
66+
'Accept': 'application/json; charset=utf-8',
67+
'Authorization': auth,
68+
};
69+
70+
const fetchOptions: RequestInit = {
71+
method,
72+
headers,
73+
body: method === 'POST' ? JSON.stringify(body) : undefined,
74+
};
75+
76+
const response = await fetch(url, fetchOptions);
77+
const isJSON = response.headers.get('content-type')?.includes('application/json');
78+
const data = isJSON ? await response.json() : await response.text();
79+
80+
if (!response.ok) {
81+
throw new Error(
82+
`BrowserStack API Error: ${response.status} ${response.statusText}${JSON.stringify(data)}`
83+
);
84+
}
85+
86+
return data;
87+
};
88+
89+
export const startBrowserStackSession: BrowserStackAPI['startSession'] = async (
90+
options: StartSessionOptions
91+
) => {
92+
const auth = getAuth(CONFIG.demoUsername, CONFIG.demoAccessKey);
93+
const hubUrl =
94+
options.hubUrl ||
95+
CONFIG.hubUrl;
96+
97+
const capabilities = options.capabilities;
98+
99+
// WebDriver requires the payload to be under "capabilities" → "alwaysMatch"
100+
const body = {
101+
capabilities: {
102+
alwaysMatch: capabilities,
103+
},
104+
};
105+
console.log(body)
106+
const data = await sendRequest('POST', hubUrl + '/session', body, auth);
107+
108+
const sessionId =
109+
data?.value?.sessionId || data?.sessionId || data?.value?.session_id;
110+
111+
return {
112+
sessionId,
113+
raw: data,
114+
};
115+
};
35116

36-
export const startBrowserStackSession:BrowserStackAPI['startSession'] = async (options:StartSessionOptions)=>{
117+
export const stopBrowserStackSession: BrowserStackAPI['stopSession'] = async (
118+
options: StopSessionOptions
119+
) => {
120+
// Get auth credentials (can be per-user or from config defaults)
121+
const auth = getAuth(CONFIG.demoUsername, CONFIG.demoAccessKey);
122+
123+
// Determine hub URL (defaults to BrowserStack Selenium Hub)
124+
const hubUrl =
125+
options.hubUrl ||
126+
CONFIG.hubUrl ||
127+
'https://hub-cloud.browserstack.com/wd/hub';
128+
129+
// Construct session endpoint
130+
const sessionUrl = `${hubUrl}/session/${options.sessionId}`;
131+
132+
// Perform DELETE request to end the session
133+
const response = await sendRequest('DELETE', sessionUrl, {}, auth);
134+
135+
return {
136+
success: true,
137+
sessionId: options.sessionId,
138+
raw: response,
139+
};
140+
};
141+
142+
export const executeCommand: BrowserStackAPI['executeCommand'] = async (
143+
options: ExecuteCommandOptions
144+
) => {
145+
const { request, sessionId } = options;
146+
147+
const hubUrl =
148+
options.hubUrl ||
149+
CONFIG.hubUrl ||
150+
'https://hub-cloud.browserstack.com/wd/hub';
151+
152+
const auth = getAuth(CONFIG.demoUsername, CONFIG.demoAccessKey);
153+
154+
let endpoint = request.endpoint;
155+
let body = request.data;
156+
157+
return sendRequest(
158+
request.method,
159+
`${hubUrl}/session/${sessionId}${endpoint}`,
160+
body,
161+
auth
162+
);
163+
};
164+
165+
/**
166+
* Deep-replaces all appearances of elementId inside objects and arrays.
167+
*/
168+
function replaceElementIdDeep(obj: any, newId: string): any {
169+
if (obj === null || obj === undefined) return obj;
170+
171+
// Replace scalar strings equal to an elementId
172+
if (typeof obj === "string") {
173+
return obj;
174+
}
175+
176+
// Replace element reference objects
177+
if (typeof obj === "object") {
178+
// Handle WebDriver element references
179+
if (obj.ELEMENT) obj.ELEMENT = newId;
180+
if (obj["element-6066-11e4-a52e-4f735466cecf"])
181+
obj["element-6066-11e4-a52e-4f735466cecf"] = newId;
182+
183+
// Handle W3C Actions API origin element
184+
if (obj.type === "pointerMove" && obj.origin && typeof obj.origin === "object") {
185+
if (obj.origin.ELEMENT || obj.origin["element-6066-11e4-a52e-4f735466cecf"]) {
186+
obj.origin = newId;
187+
}
188+
}
189+
190+
// Deep recursion
191+
for (const key of Object.keys(obj)) {
192+
obj[key] = replaceElementIdDeep(obj[key], newId);
193+
}
194+
}
195+
196+
// Handle array recursively
197+
if (Array.isArray(obj)) {
198+
return obj.map(item => replaceElementIdDeep(item, newId));
199+
}
200+
201+
return obj;
202+
}
37203

38-
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import {shell} from 'electron'
2+
3+
4+
export async function openExternalUrl(url:string){
5+
await shell.openExternal(url)
6+
}

src/constants/ipc-channels.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ const CHANNELS = {
66
GET_DEMO_CREDENTIALS:'GET_DEMO_CREDENTIALS',
77
GET_BROWSERSTACK_AUTOMATE_SESSION:'GET_BROWSERSTACK_AUTOMATE_SESSION',
88
GET_BROWSERSTACK_AUTOMATE_PARSED_TEXT_LOGS:'GET_BROWSERSTACK_AUTOMATE_PARSED_TEXT_LOGS',
9-
BROWSERSTACK_START_SESSION:'BROWSERSTACK_START_SESSION'
9+
BROWSERSTACK_START_SESSION:'BROWSERSTACK_START_SESSION',
10+
BROWSERSTACK_STOP_SESSION:'BROWSERSTACK_STOP_SESSION',
11+
BROWSERSTACK_EXECUTE_SESSION_COMMAND:'BROWSERSTACK_EXECUTE_SESSION_COMMAND',
12+
ELECTRON_OPEN_URL:'ELECTRON_OPEN_URL'
1013
}
1114

1215
export default CHANNELS

src/global.d.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ declare global {
1010
type BrowserStackAPI = {
1111
getAutomateSessionDetails: (id: string) => Promise<AutomateSessionResponse>
1212
getAutomateParsedTextLogs: (session: AutomateSessionResponse) => Promise<ParsedTextLogsResult>
13-
startSession: (options:StartSessionOptions) => any
13+
startSession: (options: StartSessionOptions) => Promise<StartSessionResponse>
14+
stopSession: (options: StopSessionOptions) => Promise<StopSessionResponse>
15+
executeCommand: (options: ExecuteCommandOptions) => any
16+
}
17+
18+
type ElectronAPI = {
19+
openExternalUrl: (url: string) => Promise<void>
1420
}
1521

1622
interface DBItem {
@@ -24,7 +30,8 @@ declare global {
2430

2531
interface Window {
2632
credentialsAPI: CredentialsAPI;
27-
browserstackAPI: BrowserStackAPI
33+
browserstackAPI: BrowserStackAPI;
34+
electronAPI: ElectronAPI
2835
}
2936

3037
interface ProductPageProps {
@@ -68,6 +75,7 @@ declare global {
6875
method: string;
6976
endpoint: string;
7077
data: Record<string, unknown> | string;
78+
commandName: string
7179
}
7280

7381
interface ParsedTextLogsResult {
@@ -78,8 +86,29 @@ declare global {
7886

7987
type StartSessionOptions = {
8088
capabilities: Record<string, any>
81-
username?: string
82-
accessKey?: string
89+
hubUrl?: string
90+
}
91+
92+
type StartSessionResponse = {
93+
sessionId: string
94+
raw: any
95+
}
96+
97+
type StopSessionOptions = {
98+
hubUrl?: string,
99+
sessionId: string
100+
}
101+
102+
type StopSessionResponse = {
103+
success: boolean,
104+
sessionId: string,
105+
raw: any,
106+
}
107+
108+
type ExecuteCommandOptions = {
109+
request: ParsedTextLogsRequest
110+
response: any
111+
sessionId: string
83112
hubUrl?: string
84113
}
85114

src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import StorageKeys from './constants/storage-keys';
88
import CONFIG from './constants/config';
99

1010
import { mkdirSync } from 'fs'
11-
import { getAutomateSessionDetails, getParsedAutomateTextLogs, startBrowserStackSession } from './channelHandlers/browserstack-api';
11+
import { executeCommand, getAutomateSessionDetails, getParsedAutomateTextLogs, startBrowserStackSession, stopBrowserStackSession } from './channelHandlers/browserstack-api';
12+
import { openExternalUrl } from './channelHandlers/electron-api';
1213
// This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Webpack
1314
// plugin that tells the Electron app where to look for the Webpack-bundled app code (depending on
1415
// whether you're running in development or production).
@@ -93,6 +94,9 @@ app.whenReady().then(() => {
9394
ipcMain.handle(CHANNELS.GET_BROWSERSTACK_AUTOMATE_SESSION, (_, id) => getAutomateSessionDetails(id))
9495
ipcMain.handle(CHANNELS.GET_BROWSERSTACK_AUTOMATE_PARSED_TEXT_LOGS, (_, session) => getParsedAutomateTextLogs(session))
9596
ipcMain.handle(CHANNELS.BROWSERSTACK_START_SESSION, (_, options) => startBrowserStackSession(options))
97+
ipcMain.handle(CHANNELS.BROWSERSTACK_STOP_SESSION, (_, options) => stopBrowserStackSession(options))
98+
ipcMain.handle(CHANNELS.BROWSERSTACK_EXECUTE_SESSION_COMMAND, (_, options) => executeCommand(options))
99+
ipcMain.handle(CHANNELS.ELECTRON_OPEN_URL, (_, url) => openExternalUrl(url))
96100
});
97101
// In this file you can include the rest of your app's specific main process
98102
// code. You can also put them in separate files and import them here.

src/preload.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,21 @@ const credentialsAPI: CredentialsAPI = {
66
setBrowserStackAdminCredentials: (username: string, accessKey: string, _rev?: string) => ipcRenderer.invoke(CHANNELS.POST_ADMIN_CREDENTIALS, username, accessKey, _rev),
77
getBrowserStackAdminCredentials: () => ipcRenderer.invoke(CHANNELS.GET_ADMIN_CREDENTIALS),
88
setBrowserStackDemoCredentials: (username: string, accessKey: string, _rev?: string) => ipcRenderer.invoke(CHANNELS.POST_DEMO_CREDENTIALS, username, accessKey, _rev),
9-
getBrowserStackDemoCredentials: ()=>ipcRenderer.invoke(CHANNELS.GET_DEMO_CREDENTIALS),
9+
getBrowserStackDemoCredentials: () => ipcRenderer.invoke(CHANNELS.GET_DEMO_CREDENTIALS),
1010
}
1111

1212
const browserstackAPI: BrowserStackAPI = {
13-
getAutomateSessionDetails: (id:string)=> ipcRenderer.invoke(CHANNELS.GET_BROWSERSTACK_AUTOMATE_SESSION,id),
14-
getAutomateParsedTextLogs: (session)=>ipcRenderer.invoke(CHANNELS.GET_BROWSERSTACK_AUTOMATE_PARSED_TEXT_LOGS,session),
15-
startSession:(options)=>ipcRenderer.invoke(CHANNELS.BROWSERSTACK_START_SESSION,options)
13+
getAutomateSessionDetails: (id: string) => ipcRenderer.invoke(CHANNELS.GET_BROWSERSTACK_AUTOMATE_SESSION, id),
14+
getAutomateParsedTextLogs: (session) => ipcRenderer.invoke(CHANNELS.GET_BROWSERSTACK_AUTOMATE_PARSED_TEXT_LOGS, session),
15+
startSession: (options) => ipcRenderer.invoke(CHANNELS.BROWSERSTACK_START_SESSION, options),
16+
stopSession: (options) => ipcRenderer.invoke(CHANNELS.BROWSERSTACK_STOP_SESSION, options),
17+
executeCommand: (options) => ipcRenderer.invoke(CHANNELS.BROWSERSTACK_EXECUTE_SESSION_COMMAND, options)
18+
}
19+
20+
const electronAPI: ElectronAPI = {
21+
openExternalUrl: (url: string) => ipcRenderer.invoke(CHANNELS.ELECTRON_OPEN_URL, url)
1622
}
1723

1824
contextBridge.exposeInMainWorld('credentialsAPI', credentialsAPI);
19-
contextBridge.exposeInMainWorld('browserstackAPI',browserstackAPI);
25+
contextBridge.exposeInMainWorld('browserstackAPI', browserstackAPI);
26+
contextBridge.exposeInMainWorld('electronAPI', electronAPI)

0 commit comments

Comments
 (0)