Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const config: ForgeConfig = {
],
},
devContentSecurityPolicy:
"default-src 'self' 'unsafe-inline' static:; script-src 'self' 'unsafe-eval' 'unsafe-inline';",
"default-src 'self' 'unsafe-inline' static:;img-src 'self' data: static:;script-src 'self' 'unsafe-eval' 'unsafe-inline';",
}),
// Fuses are used to enable/disable various Electron functionality
// at package time, before code signing the application
Expand Down
179 changes: 172 additions & 7 deletions src/channelHandlers/browserstack-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import CONFIG from "../constants/config"

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

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

Expand All @@ -27,12 +27,177 @@ export const getAutomateSessionDetails: BrowserStackAPI['getAutomateSessionDetai
return sessionDetailsJSON
}

export const getParsedAutomateTextLogs = async (session:AutomateSessionResponse) => {
export const getParsedAutomateTextLogs = async (session: AutomateSessionResponse) => {
const logs = await download(session.automation_session.logs);
const result = parseAutomateTextLogs(logs.split('\n'))
return result
}
const lines = logs.split('\n');

const timestampRegex = /^\d{4}-\d{2}-\d{2} \d{1,2}:\d{1,2}:\d{1,2}:\d{1,3}/;

const entries: string[] = [];

for (const line of lines) {
if (timestampRegex.test(line)) {
// New log entry → push as a new entry
entries.push(line);
} else if (entries.length > 0) {
// Continuation of previous entry → append
entries[entries.length - 1] += '\n' + line;
} else {
// Edge case: first line doesn't start with timestamp
entries.push(line);
}
}

console.log(entries)

return parseAutomateTextLogs(entries);
};

const sendRequest = async (method: string, url: string, body: any = {}, auth: string) => {
delete body.fetchRawLogs;

// BrowserStack WebDriver quirk: convert "text" → "value" array for sendKeys
// if (util.getCommandName?.(url) === 'sendKeys' && !body['value'] && body['text']) {
// body['value'] = body['text'].split('');
// }

const headers = {
'Content-Type': 'application/json; charset=utf-8',
'Accept': 'application/json; charset=utf-8',
'Authorization': auth,
};

const fetchOptions: RequestInit = {
method,
headers,
body: method === 'POST' ? JSON.stringify(body) : undefined,
};

const response = await fetch(url, fetchOptions);
const isJSON = response.headers.get('content-type')?.includes('application/json');
const data = isJSON ? await response.json() : await response.text();

if (!response.ok) {
throw new Error(
`BrowserStack API Error: ${response.status} ${response.statusText} — ${JSON.stringify(data)}`
);
}

return data;
};

export const startBrowserStackSession: BrowserStackAPI['startSession'] = async (
options: StartSessionOptions
) => {
const auth = getAuth(CONFIG.demoUsername, CONFIG.demoAccessKey);
const hubUrl =
options.hubUrl ||
CONFIG.hubUrl;

const capabilities = options.capabilities;

// WebDriver requires the payload to be under "capabilities" → "alwaysMatch"
const body = {
capabilities: {
alwaysMatch: capabilities,
},
};
console.log(body)
const data = await sendRequest('POST', hubUrl + '/session', body, auth);

const sessionId =
data?.value?.sessionId || data?.sessionId || data?.value?.session_id;

return {
sessionId,
raw: data,
};
};

export const startBrowserStackSession:BrowserStackAPI['startSession'] = async (options:StartSessionOptions)=>{
export const stopBrowserStackSession: BrowserStackAPI['stopSession'] = async (
options: StopSessionOptions
) => {
// Get auth credentials (can be per-user or from config defaults)
const auth = getAuth(CONFIG.demoUsername, CONFIG.demoAccessKey);

// Determine hub URL (defaults to BrowserStack Selenium Hub)
const hubUrl =
options.hubUrl ||
CONFIG.hubUrl ||
'https://hub-cloud.browserstack.com/wd/hub';

// Construct session endpoint
const sessionUrl = `${hubUrl}/session/${options.sessionId}`;

// Perform DELETE request to end the session
const response = await sendRequest('DELETE', sessionUrl, {}, auth);

return {
success: true,
sessionId: options.sessionId,
raw: response,
};
};

export const executeCommand: BrowserStackAPI['executeCommand'] = async (
options: ExecuteCommandOptions
) => {
const { request, sessionId } = options;

const hubUrl =
options.hubUrl ||
CONFIG.hubUrl ||
'https://hub-cloud.browserstack.com/wd/hub';

const auth = getAuth(CONFIG.demoUsername, CONFIG.demoAccessKey);

let endpoint = request.endpoint;
let body = request.data;

return sendRequest(
request.method,
`${hubUrl}/session/${sessionId}${endpoint}`,
body,
auth
);
};

/**
* Deep-replaces all appearances of elementId inside objects and arrays.
*/
function replaceElementIdDeep(obj: any, newId: string): any {
if (obj === null || obj === undefined) return obj;

// Replace scalar strings equal to an elementId
if (typeof obj === "string") {
return obj;
}

// Replace element reference objects
if (typeof obj === "object") {
// Handle WebDriver element references
if (obj.ELEMENT) obj.ELEMENT = newId;
if (obj["element-6066-11e4-a52e-4f735466cecf"])
obj["element-6066-11e4-a52e-4f735466cecf"] = newId;

// Handle W3C Actions API origin element
if (obj.type === "pointerMove" && obj.origin && typeof obj.origin === "object") {
if (obj.origin.ELEMENT || obj.origin["element-6066-11e4-a52e-4f735466cecf"]) {
obj.origin = newId;
}
}

// Deep recursion
for (const key of Object.keys(obj)) {
obj[key] = replaceElementIdDeep(obj[key], newId);
}
}

// Handle array recursively
if (Array.isArray(obj)) {
return obj.map(item => replaceElementIdDeep(item, newId));
}

return obj;
}

}
6 changes: 6 additions & 0 deletions src/channelHandlers/electron-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import {shell} from 'electron'


export async function openExternalUrl(url:string){
await shell.openExternal(url)
}
5 changes: 4 additions & 1 deletion src/constants/ipc-channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ const CHANNELS = {
GET_DEMO_CREDENTIALS:'GET_DEMO_CREDENTIALS',
GET_BROWSERSTACK_AUTOMATE_SESSION:'GET_BROWSERSTACK_AUTOMATE_SESSION',
GET_BROWSERSTACK_AUTOMATE_PARSED_TEXT_LOGS:'GET_BROWSERSTACK_AUTOMATE_PARSED_TEXT_LOGS',
BROWSERSTACK_START_SESSION:'BROWSERSTACK_START_SESSION'
BROWSERSTACK_START_SESSION:'BROWSERSTACK_START_SESSION',
BROWSERSTACK_STOP_SESSION:'BROWSERSTACK_STOP_SESSION',
BROWSERSTACK_EXECUTE_SESSION_COMMAND:'BROWSERSTACK_EXECUTE_SESSION_COMMAND',
ELECTRON_OPEN_URL:'ELECTRON_OPEN_URL'
}

export default CHANNELS
37 changes: 33 additions & 4 deletions src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ declare global {
type BrowserStackAPI = {
getAutomateSessionDetails: (id: string) => Promise<AutomateSessionResponse>
getAutomateParsedTextLogs: (session: AutomateSessionResponse) => Promise<ParsedTextLogsResult>
startSession: (options:StartSessionOptions) => any
startSession: (options: StartSessionOptions) => Promise<StartSessionResponse>
stopSession: (options: StopSessionOptions) => Promise<StopSessionResponse>
executeCommand: (options: ExecuteCommandOptions) => any
}

type ElectronAPI = {
openExternalUrl: (url: string) => Promise<void>
}

interface DBItem {
Expand All @@ -24,7 +30,8 @@ declare global {

interface Window {
credentialsAPI: CredentialsAPI;
browserstackAPI: BrowserStackAPI
browserstackAPI: BrowserStackAPI;
electronAPI: ElectronAPI
}

interface ProductPageProps {
Expand Down Expand Up @@ -68,6 +75,7 @@ declare global {
method: string;
endpoint: string;
data: Record<string, unknown> | string;
commandName: string
}

interface ParsedTextLogsResult {
Expand All @@ -78,8 +86,29 @@ declare global {

type StartSessionOptions = {
capabilities: Record<string, any>
username?: string
accessKey?: string
hubUrl?: string
}

type StartSessionResponse = {
sessionId: string
raw: any
}

type StopSessionOptions = {
hubUrl?: string,
sessionId: string
}

type StopSessionResponse = {
success: boolean,
sessionId: string,
raw: any,
}

type ExecuteCommandOptions = {
request: ParsedTextLogsRequest
response: any
sessionId: string
hubUrl?: string
}

Expand Down
6 changes: 5 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import StorageKeys from './constants/storage-keys';
import CONFIG from './constants/config';

import { mkdirSync } from 'fs'
import { getAutomateSessionDetails, getParsedAutomateTextLogs, startBrowserStackSession } from './channelHandlers/browserstack-api';
import { executeCommand, getAutomateSessionDetails, getParsedAutomateTextLogs, startBrowserStackSession, stopBrowserStackSession } from './channelHandlers/browserstack-api';
import { openExternalUrl } from './channelHandlers/electron-api';
// This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Webpack
// plugin that tells the Electron app where to look for the Webpack-bundled app code (depending on
// whether you're running in development or production).
Expand Down Expand Up @@ -93,6 +94,9 @@ app.whenReady().then(() => {
ipcMain.handle(CHANNELS.GET_BROWSERSTACK_AUTOMATE_SESSION, (_, id) => getAutomateSessionDetails(id))
ipcMain.handle(CHANNELS.GET_BROWSERSTACK_AUTOMATE_PARSED_TEXT_LOGS, (_, session) => getParsedAutomateTextLogs(session))
ipcMain.handle(CHANNELS.BROWSERSTACK_START_SESSION, (_, options) => startBrowserStackSession(options))
ipcMain.handle(CHANNELS.BROWSERSTACK_STOP_SESSION, (_, options) => stopBrowserStackSession(options))
ipcMain.handle(CHANNELS.BROWSERSTACK_EXECUTE_SESSION_COMMAND, (_, options) => executeCommand(options))
ipcMain.handle(CHANNELS.ELECTRON_OPEN_URL, (_, url) => openExternalUrl(url))
});
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and import them here.
17 changes: 12 additions & 5 deletions src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,21 @@ const credentialsAPI: CredentialsAPI = {
setBrowserStackAdminCredentials: (username: string, accessKey: string, _rev?: string) => ipcRenderer.invoke(CHANNELS.POST_ADMIN_CREDENTIALS, username, accessKey, _rev),
getBrowserStackAdminCredentials: () => ipcRenderer.invoke(CHANNELS.GET_ADMIN_CREDENTIALS),
setBrowserStackDemoCredentials: (username: string, accessKey: string, _rev?: string) => ipcRenderer.invoke(CHANNELS.POST_DEMO_CREDENTIALS, username, accessKey, _rev),
getBrowserStackDemoCredentials: ()=>ipcRenderer.invoke(CHANNELS.GET_DEMO_CREDENTIALS),
getBrowserStackDemoCredentials: () => ipcRenderer.invoke(CHANNELS.GET_DEMO_CREDENTIALS),
}

const browserstackAPI: BrowserStackAPI = {
getAutomateSessionDetails: (id:string)=> ipcRenderer.invoke(CHANNELS.GET_BROWSERSTACK_AUTOMATE_SESSION,id),
getAutomateParsedTextLogs: (session)=>ipcRenderer.invoke(CHANNELS.GET_BROWSERSTACK_AUTOMATE_PARSED_TEXT_LOGS,session),
startSession:(options)=>ipcRenderer.invoke(CHANNELS.BROWSERSTACK_START_SESSION,options)
getAutomateSessionDetails: (id: string) => ipcRenderer.invoke(CHANNELS.GET_BROWSERSTACK_AUTOMATE_SESSION, id),
getAutomateParsedTextLogs: (session) => ipcRenderer.invoke(CHANNELS.GET_BROWSERSTACK_AUTOMATE_PARSED_TEXT_LOGS, session),
startSession: (options) => ipcRenderer.invoke(CHANNELS.BROWSERSTACK_START_SESSION, options),
stopSession: (options) => ipcRenderer.invoke(CHANNELS.BROWSERSTACK_STOP_SESSION, options),
executeCommand: (options) => ipcRenderer.invoke(CHANNELS.BROWSERSTACK_EXECUTE_SESSION_COMMAND, options)
}

const electronAPI: ElectronAPI = {
openExternalUrl: (url: string) => ipcRenderer.invoke(CHANNELS.ELECTRON_OPEN_URL, url)
}

contextBridge.exposeInMainWorld('credentialsAPI', credentialsAPI);
contextBridge.exposeInMainWorld('browserstackAPI',browserstackAPI);
contextBridge.exposeInMainWorld('browserstackAPI', browserstackAPI);
contextBridge.exposeInMainWorld('electronAPI', electronAPI)
Loading
Loading