diff --git a/forge.config.ts b/forge.config.ts index d2261b6..7c712d8 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -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 diff --git a/src/channelHandlers/browserstack-api.ts b/src/channelHandlers/browserstack-api.ts index df82635..d32c4db 100644 --- a/src/channelHandlers/browserstack-api.ts +++ b/src/channelHandlers/browserstack-api.ts @@ -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')}` } @@ -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; +} -} \ No newline at end of file diff --git a/src/channelHandlers/electron-api.ts b/src/channelHandlers/electron-api.ts new file mode 100644 index 0000000..a0d56e8 --- /dev/null +++ b/src/channelHandlers/electron-api.ts @@ -0,0 +1,6 @@ +import {shell} from 'electron' + + +export async function openExternalUrl(url:string){ + await shell.openExternal(url) +} \ No newline at end of file diff --git a/src/constants/ipc-channels.ts b/src/constants/ipc-channels.ts index 207d09e..f75ebdc 100644 --- a/src/constants/ipc-channels.ts +++ b/src/constants/ipc-channels.ts @@ -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 \ No newline at end of file diff --git a/src/global.d.ts b/src/global.d.ts index e3ed60d..52a1047 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -10,7 +10,13 @@ declare global { type BrowserStackAPI = { getAutomateSessionDetails: (id: string) => Promise getAutomateParsedTextLogs: (session: AutomateSessionResponse) => Promise - startSession: (options:StartSessionOptions) => any + startSession: (options: StartSessionOptions) => Promise + stopSession: (options: StopSessionOptions) => Promise + executeCommand: (options: ExecuteCommandOptions) => any + } + + type ElectronAPI = { + openExternalUrl: (url: string) => Promise } interface DBItem { @@ -24,7 +30,8 @@ declare global { interface Window { credentialsAPI: CredentialsAPI; - browserstackAPI: BrowserStackAPI + browserstackAPI: BrowserStackAPI; + electronAPI: ElectronAPI } interface ProductPageProps { @@ -68,6 +75,7 @@ declare global { method: string; endpoint: string; data: Record | string; + commandName: string } interface ParsedTextLogsResult { @@ -78,8 +86,29 @@ declare global { type StartSessionOptions = { capabilities: Record - 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 } diff --git a/src/index.ts b/src/index.ts index 483ba85..66488cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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). @@ -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. diff --git a/src/preload.ts b/src/preload.ts index 587f8ca..e8d35cc 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -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); \ No newline at end of file +contextBridge.exposeInMainWorld('browserstackAPI', browserstackAPI); +contextBridge.exposeInMainWorld('electronAPI', electronAPI) \ No newline at end of file diff --git a/src/renderer/components/replay-tool/request-card.tsx b/src/renderer/components/replay-tool/request-card.tsx new file mode 100644 index 0000000..f6e1a53 --- /dev/null +++ b/src/renderer/components/replay-tool/request-card.tsx @@ -0,0 +1,65 @@ +import React, { useState } from "react"; + +export default function RequestCard({ request }: { request: ParsedTextLogsRequest | string }) { + const isString = typeof request === "string"; + + // If string → show simple message card + if (isString) { + return ( +
+
+ {request} +
+
+ ); + } + + // Structured request version + const { method, endpoint, data, commandName } = request; + const [open, setOpen] = useState(true); + + const methodColor = { + GET: "badge-info", + POST: "badge-success", + DELETE: "badge-error", + PUT: "badge-warning", + }[method] || "badge-neutral"; + + return ( +
+
+ + {/* Header row */} +
+
+ {method} + {commandName} + + {commandName && ( +
+ Endpoint: {endpoint} +
+ )} +
+ + {/* Toggle JSON button */} + {/* {data && ( + + )} */} +
+ + {/* Data area */} + {open && data && ( +
+                        {JSON.stringify(data, null, 2)}
+                    
+ )} +
+
+ ); +} diff --git a/src/renderer/components/replay-tool/session-player.tsx b/src/renderer/components/replay-tool/session-player.tsx index 20fcc98..0812402 100644 --- a/src/renderer/components/replay-tool/session-player.tsx +++ b/src/renderer/components/replay-tool/session-player.tsx @@ -1,20 +1,190 @@ -import { useState } from "react" +import { useEffect, useMemo, useState } from "react" +import { toast } from "react-toastify" +import SessionPlayer from "../../utils/session-player" export type SessionPlayerProps = { parsedTextLogs: ParsedTextLogsResult sessionDetails: AutomateSessionResponse overridenCaps?: any - loading?:boolean - onExecutionStateChange:(executing:boolean)=>void + loading?: boolean + onExecutionStateChange: (executing: boolean) => void } -export default function SessionPlayer(props:SessionPlayerProps){ - const {parsedTextLogs, sessionDetails,loading} = props - const [hubURL,SetHubURL] = useState() +type ExecutionLog = { + index: number; + timestamp: number; + req: ParsedTextLogsRequest | string; + res: any; + status: "ok" | "error" | "sleep" | "noop"; +}; +export default function SessionPlayerComponent(props: SessionPlayerProps) { + const { parsedTextLogs, loading, overridenCaps } = props; + const [hubURL, SetHubURL] = useState(); + const [isExecuting, SetIsExecuting] = useState(false); + const [sessionId, SetSessionId] = useState(); + const [isStoppingSession, SetIsStoppingSession] = useState(false); + const [isSessionStopped, SetIsSessionStopped] = useState(false) + const sessionPlayer = useMemo(() => { + return new SessionPlayer(parsedTextLogs, hubURL) + }, [parsedTextLogs, hubURL]) + const [executionLogs, SetExecutionLogs] = useState([]); + const [newSessionDetails, SetNewSesssionDetails] = useState() + + console.log("Parsed Logs", parsedTextLogs) + + const startSession = async () => { + SetExecutionLogs([]); // clear logs + SetIsExecuting(true); + + try { + const res = await toast.promise( + sessionPlayer.startSession(overridenCaps), + { + pending: "Starting Session...", + error: "Failed to start session", + success: "Session Started" + } + ); + + SetSessionId(res.sessionId); + const newSessionDetails = await window.browserstackAPI.getAutomateSessionDetails(res.sessionId); + SetNewSesssionDetails(newSessionDetails) + await startExecution(); + + } catch { + SetIsExecuting(false); + } + }; + + const startExecution = async () => { + let step = 0; + + for await (const data of sessionPlayer.executeNextRequest()) { + SetExecutionLogs((prev) => [ + ...prev, + { + index: step++, + timestamp: Date.now(), + req: data.req, + res: data.res, + status: + typeof data.req === "string" && data.req.startsWith("SLEEP") + ? "sleep" + : data.res?.error + ? "error" + : "ok" + } + ]); + if (data.res.error) { + break; + } + } + + stopSession() + }; + + const stopSession = async () => { + SetIsStoppingSession(true); + try { + await toast.promise(sessionPlayer.stopSession(), { + pending: "Stopping Session...", + error: "Failed to stop session", + success: "Session Stopped successfully" + }); + } finally { + SetIsStoppingSession(false); + SetIsSessionStopped(true) + } + }; + + const sessionDone = () => { + SetIsExecuting(false) + SetNewSesssionDetails(undefined) + SetIsSessionStopped(false) + SetSessionId(undefined) + } + + const openAutomateSession = () => { + window.electronAPI.openExternalUrl(newSessionDetails.automation_session.browser_url) + } + + useEffect(() => { + props.onExecutionStateChange(isExecuting); + }, [isExecuting]); + return ( -
- - SetHubURL(e.target.value)} className="input placeholder-gray-300 w-full" placeholder="Leave empty if you don't want to override" /> - +
+ {!isExecuting ? ( + + ) : ( + <> +
+ {newSessionDetails && } + +
+ + {/* logs */} +
+ {executionLogs.map(log => ( +
+
+ + + + + +
+
+ +
+
{JSON.stringify(log.req, null, 2)}
+
+ + +
+
{JSON.stringify(log.res, null, 2)}
+
+
+
+
+
+ ))} + + {isExecuting && !isSessionStopped && ( + + )} +
+ + )}
- ) -} \ No newline at end of file + ); +} diff --git a/src/renderer/layout.tsx b/src/renderer/layout.tsx index 27bda89..e0ca1b9 100644 --- a/src/renderer/layout.tsx +++ b/src/renderer/layout.tsx @@ -4,13 +4,13 @@ import Sidebar from "./components/sidebar"; import { ToastContainer } from 'react-toastify'; export default function Layout() { return ( -
+
-
+
-
+
diff --git a/src/renderer/routes/automate/tools/replay-tool.tsx b/src/renderer/routes/automate/tools/replay-tool.tsx index 2d8e547..b73cd13 100644 --- a/src/renderer/routes/automate/tools/replay-tool.tsx +++ b/src/renderer/routes/automate/tools/replay-tool.tsx @@ -5,6 +5,7 @@ import Editor from 'react-simple-code-editor'; import { highlight } from 'sugar-high' import { useState } from "react"; import SessionPlayer from "../../../components/replay-tool/session-player"; +import RequestCard from "../../../components/replay-tool/request-card"; const { Field } = Form function Info({ label, value }: { label: string; value: string }) { @@ -58,7 +59,8 @@ export default function ReplayTool() { const [fetchSessionDetails, fetchingSession, session] = usePromise(window.browserstackAPI.getAutomateSessionDetails); const [parseTextLogs, parsingTextLogs, textLogsResult] = usePromise(window.browserstackAPI.getAutomateParsedTextLogs) const [capabilities, SetCapabilities] = useState('') - const [isExecuting,SetIsExecuting] = useState(false) + const [isExecuting, SetIsExecuting] = useState(false) + const [hubURL, SetHubURL] = useState(null) const OpenSession = (input: any) => { toast.promise(fetchSessionDetails(input.sessionId).then((res) => parseTextLogs(res)), { pending: "Opening Session...", @@ -82,7 +84,7 @@ export default function ReplayTool() { - {session && <> + {textLogsResult && session && !isExecuting && <>

@@ -118,17 +120,33 @@ export default function ReplayTool() { />

} +
+ {textLogsResult &&
+ Commands +
+ {textLogsResult.requests.map((request) => ( + + ))} +
+
} + {textLogsResult &&
- + {textLogsResult && !isExecuting &&
+ + +
}
} } + {textLogsResult && }
) } \ No newline at end of file diff --git a/src/renderer/utils/session-player.tsx b/src/renderer/utils/session-player.tsx new file mode 100644 index 0000000..32a703d --- /dev/null +++ b/src/renderer/utils/session-player.tsx @@ -0,0 +1,330 @@ +export default class SessionPlayer { + // --- Constants --- + static readonly RESPONSE_ELEMENT_ID_KEY = 'element-6066-11e4-a52e-4f735466cecf'; + static readonly SENDKEYS_REQUEST_URL_REGEX = /\/element\/.[^\/]+\/value$/; + static readonly ELEMENT_REQUEST_URL_REGEX = /\/element\/[0-9a-zA-Z.-]+\/[a-z]+/; + static readonly ACTIONS_REQUEST_URL_REGEX = /\/actions$/; + + sessionId?: string; + + constructor( + private textLogsResult: ParsedTextLogsResult, + private hubUrl?: string + ) { } + + // --------------------------------------------------------- + // Logging helper + // --------------------------------------------------------- + private log(level: "debug" | "info" | "warn" | "error", msg: string, data?: any) { + const prefix = `[SessionPlayer]`; + if (data !== undefined) console[level](`${prefix} ${msg}`, data); + else console[level](`${prefix} ${msg}`); + } + + // --------------------------------------------------------- + // Session control + // --------------------------------------------------------- + async startSession(caps: any) { + this.log("info", "Starting session…"); + + const res = await window.browserstackAPI.startSession({ + capabilities: JSON.parse(caps), + hubUrl: this.hubUrl + }); + + this.sessionId = res.sessionId; + this.log("info", `Session started`, { sessionId: this.sessionId }); + + return res; + } + + async stopSession() { + this.log("info", "Stopping session…", { sessionId: this.sessionId }); + const res = await window.browserstackAPI.stopSession({ + sessionId: this.sessionId, + hubUrl: this.hubUrl + }); + this.log("info", "Session stopped."); + return res; + } + + setHubUrl(url: string) { + this.log("info", `Hub URL updated: ${url}`); + this.hubUrl = url; + } + + async sleep(seconds: number) { + this.log("debug", `Sleeping for ${seconds}s…`); + return new Promise((res) => setTimeout(res, seconds * 1000)); + } + + // --------------------------------------------------------- + // Main iterator + // --------------------------------------------------------- + async *executeNextRequest() { + this.log("info", "Starting request replay…"); + + const requests = this.textLogsResult.requests; + const responses = this.textLogsResult.responses; + + let sessionElementIds: string[] = []; + let rawLogElementIds: string[] = []; + let mappedElementId = ''; + let res: any + + for (let i = 0; i < requests.length; i++) { + this.log("debug", "Iteration", i) + const req = requests[i]; + const rawResponse = responses[i]; + const sentRequest: ParsedTextLogsRequest | string = JSON.parse(JSON.stringify(req)); + + this.log("debug", `Processing request #${i}`, { req }); + + // ----- Handle SLEEP ----- + if (typeof req === "string" && req.startsWith("SLEEP")) { + const parts = req.trim().split(/\s+/); + const sleepTime = Number(parts[1]); + + this.log("info", `SLEEP command encountered: ${sleepTime}s`); + + if (!Number.isNaN(sleepTime)) { + await this.sleep(sleepTime); + } + } else if (req && typeof req !== "string" && req.endpoint === "/") { + this.log("debug", "Root endpoint encountered; skipping WebDriver call."); + } else { + + // ----- Element-ID resolution ----- + mappedElementId = this.getElementId(req as ParsedTextLogsRequest, sessionElementIds, rawLogElementIds); + this.log("debug", `Resolved elementId`, { mappedElementId }); + + // ----- Execute BrowserStack request ----- + this.log("info", `Executing command: ${(req as ParsedTextLogsRequest).endpoint}`, { + elementId: mappedElementId + }); + + try { + const isFindELements = (req as ParsedTextLogsRequest).commandName == 'findElement' || (req as ParsedTextLogsRequest).commandName == 'findElements'; + const executeCommand = () => { + if (mappedElementId) { + (sentRequest as ParsedTextLogsRequest).endpoint = this.replaceElementIdInEndpoint((sentRequest as ParsedTextLogsRequest).endpoint, mappedElementId); + if ((sentRequest as ParsedTextLogsRequest).data) { + (sentRequest as ParsedTextLogsRequest).data = this.replaceElementIdDeep((sentRequest as ParsedTextLogsRequest).data, mappedElementId); + } + } + return window.browserstackAPI.executeCommand({ + request: sentRequest as ParsedTextLogsRequest, + response: rawResponse, + hubUrl: this.hubUrl, + sessionId: this.sessionId, + }) + } + const sessionResponse = isFindELements ? await this.retryUntilTimeout(executeCommand) : await executeCommand(); + + this.log("debug", `Raw response received`, { sessionResponse }); + + // ----- Process element IDs in response ----- + const idExtract = this.fetchElementIds( + sessionResponse, + req as ParsedTextLogsRequest, + rawLogElementIds, + sessionElementIds + ); + + rawLogElementIds.push(...idExtract.rawLogsElementIdArray) + sessionElementIds.push(...idExtract.sessionElementIdArray) + + if (rawLogElementIds.length || sessionElementIds.length) { + this.log("debug", "Updated element ID arrays", { + rawLogElementIds, + sessionElementIds + }); + } + res = sessionResponse + + } catch (err) { + yield { req: sentRequest, res: { error: err } } + continue; + } + } + yield { req, res }; + } + + this.log("info", "Finished executing all requests."); + } + + // --------------------------------------------------------- + // Helpers: fetching, mapping, extracting element IDs + // --------------------------------------------------------- + private getRequestsWithoutSleep() { + return this.textLogsResult.requests.filter((req) => typeof req === "object"); + } + + private getElementIdArray(response: any) { + let arr: string[] = []; + + if (response && !response.error) { + if (response[SessionPlayer.RESPONSE_ELEMENT_ID_KEY] || response["ELEMENT"]) { + arr = [ + response[SessionPlayer.RESPONSE_ELEMENT_ID_KEY] ?? + response["ELEMENT"] + ]; + } else if (Array.isArray(response) && response[0] && + (response[0][SessionPlayer.RESPONSE_ELEMENT_ID_KEY] || response[0]["ELEMENT"]) + ) { + const key = response[0][SessionPlayer.RESPONSE_ELEMENT_ID_KEY] + ? SessionPlayer.RESPONSE_ELEMENT_ID_KEY + : "ELEMENT"; + + arr = response.map((obj: any) => obj[key]); + } + } + + return arr; + } + + private getRawLogsElementIdArray(req: ParsedTextLogsRequest) { + const realRequests = this.getRequestsWithoutSleep(); + const requestIndex = realRequests.indexOf(req); + const response = this.textLogsResult.responses?.[requestIndex]; + return this.getElementIdArray(response); + } + + private fetchElementIds( + sessionResponse: any, + request: ParsedTextLogsRequest, + rawLogIds: string[], + sessionIds: string[] + ) { + const data = sessionResponse; + + if (!data) return { rawLogsElementIdArray: rawLogIds, sessionElementIdArray: sessionIds }; + + const containsElement = + JSON.stringify(data).includes("ELEMENT") || + JSON.stringify(data).includes(SessionPlayer.RESPONSE_ELEMENT_ID_KEY); + + if (containsElement) { + const value = data.value; + sessionIds = this.getElementIdArray(value); + rawLogIds = this.getRawLogsElementIdArray(request); + } + + return { + rawLogsElementIdArray: rawLogIds, + sessionElementIdArray: sessionIds + }; + } + + private getElementId( + req: ParsedTextLogsRequest, + sessionElementIds: string[], + rawLogElementIds: string[] + ) { + let elementId = ""; + let index; + + const extractFromEndpoint = (endpoint: string) => { + const split = endpoint.split("/"); + const pos = split.indexOf("element"); + return split[pos + 1]; + }; + + // simple element commands (/element//click) + if (SessionPlayer.ELEMENT_REQUEST_URL_REGEX.test(req.endpoint)) { + elementId = extractFromEndpoint(req.endpoint); + index = rawLogElementIds.indexOf(elementId); + } + + // actions → pointerMove with ELEMENT origin + else if (req.commandName === "performActions") { + const actions = (req.data as any).actions?.[0]?.actions?.filter( + (a: any) => a.type === "pointerMove" + ); + + if (actions?.[0]) { + const origin = actions[0].origin; + index = rawLogElementIds.indexOf(origin?.ELEMENT); + } + } + + else if (req.commandName == "executeScript" || req.commandName == "executeAsyncScript") { + const args = (req.data as any).args?.[0] + if (args) { + const elementId = args[SessionPlayer.RESPONSE_ELEMENT_ID_KEY] || args["ELEMENT"] + if (elementId) { + index = rawLogElementIds.indexOf(elementId) + } + } + } + + const finalId = sessionElementIds[index] ?? ""; + this.log("debug", "Element ID mapped", { raw: elementId, mapped: finalId }); + + return finalId; + } + + private 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; + if (obj.id) obj.id = 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] = this.replaceElementIdDeep(obj[key], newId); + } + } + + // Handle array recursively + if (Array.isArray(obj)) { + return obj.map(item => this.replaceElementIdDeep(item, newId)); + } + + return obj; + } + + private async retryUntilTimeout( + fn: () => Promise, + timeoutMs: number = 30000, + intervalMs: number = 500 + ): Promise { + + const start = Date.now(); + + while (true) { + try { + const result = await fn(); // Attempt the function + return result; // Success → return immediately + } catch (err) { + // If timeout reached → throw final error + if (Date.now() - start >= timeoutMs) { + throw err + } + // Wait before retrying + await new Promise(res => setTimeout(res, intervalMs)); + } + } + } + + private replaceElementIdInEndpoint(endpoint: string, elementId: string): string { + return endpoint.replace(/element\/[0-9a-zA-Z.-]+/, 'element/' + elementId); + } +} diff --git a/src/utils/text-logs-parser.ts b/src/utils/text-logs-parser.ts index 6e042e5..570ab4a 100644 --- a/src/utils/text-logs-parser.ts +++ b/src/utils/text-logs-parser.ts @@ -5,6 +5,145 @@ const buildCapabilities = (line: string[], isAppAutomate: boolean) => { return (isAppAutomate ? caps : caps.desiredCapabilities); }; +const WebDriverCommandMap: Record = { + "POST /session": "newSession", + "DELETE /session/:sessionId": "deleteSession", + + "POST /session/:sessionId/url": "navigateTo", + "GET /session/:sessionId/url": "getCurrentUrl", + "POST /session/:sessionId/back": "navigateBack", + "POST /session/:sessionId/forward": "navigateForward", + "POST /session/:sessionId/refresh": "refresh", + + "GET /session/:sessionId/title": "getTitle", + + "POST /session/:sessionId/window": "createNewWindow", + "DELETE /session/:sessionId/window": "closeWindow", + "GET /session/:sessionId/window": "getWindowHandle", + "GET /session/:sessionId/window/handles": "getWindowHandles", + "POST /session/:sessionId/window/rect": "setWindowRect", + "GET /session/:sessionId/window/rect": "getWindowRect", + "POST /session/:sessionId/window/maximize": "maximizeWindow", + "POST /session/:sessionId/window/minimize": "minimizeWindow", + "POST /session/:sessionId/window/fullscreen": "fullscreenWindow", + + "POST /session/:sessionId/element": "findElement", + "POST /session/:sessionId/elements": "findElements", + "POST /session/:sessionId/element/:id/element": "findElementFromElement", + "POST /session/:sessionId/element/:id/elements": "findElementsFromElement", + + "GET /session/:sessionId/element/:id/attribute/:name": "getElementAttribute", + "GET /session/:sessionId/element/:id/property/:name": "getElementProperty", + "GET /session/:sessionId/element/:id/css/:propertyName": "getElementCSSValue", + "GET /session/:sessionId/element/:id/text": "getElementText", + "GET /session/:sessionId/element/:id/name": "getElementTagName", + "GET /session/:sessionId/element/:id/rect": "getElementRect", + "GET /session/:sessionId/element/:id/enabled": "isElementEnabled", + "GET /session/:sessionId/element/:id/displayed": "isElementDisplayed", + "GET /session/:sessionId/element/:id/selected": "isElementSelected", + + "POST /session/:sessionId/element/:id/click": "elementClick", + "POST /session/:sessionId/element/:id/clear": "elementClear", + "POST /session/:sessionId/element/:id/value": "elementSendKeys", + + "GET /session/:sessionId/source": "getPageSource", + + "POST /session/:sessionId/execute/sync": "executeScript", + "POST /session/:sessionId/execute/async": "executeAsyncScript", + + "POST /session/:sessionId/cookie": "addCookie", + "GET /session/:sessionId/cookie": "getCookies", + "GET /session/:sessionId/cookie/:name": "getCookie", + "DELETE /session/:sessionId/cookie": "deleteAllCookies", + "DELETE /session/:sessionId/cookie/:name": "deleteCookie", + + "GET /session/:sessionId/alert/text": "getAlertText", + "POST /session/:sessionId/alert/accept": "acceptAlert", + "POST /session/:sessionId/alert/dismiss": "dismissAlert", + "POST /session/:sessionId/alert/text": "sendAlertText", + + "POST /session/:sessionId/frame": "switchToFrame", + "POST /session/:sessionId/frame/parent": "switchToParentFrame", + + "POST /session/:sessionId/timeouts": "setTimeouts", + + "POST /session/:sessionId/actions": "performActions", + "DELETE /session/:sessionId/actions": "releaseActions", + + "GET /session/:sessionId/screenshot": "takeScreenshot", + "GET /session/:sessionId/element/:id/screenshot": "takeElementScreenshot", +}; + +const JSONWireCommandMap: Record = { + "GET /wd/hub/status": "status", + "POST /wd/hub/session": "newSession", + "DELETE /wd/hub/session/:sessionId": "deleteSession", + + "POST /wd/hub/session/:sessionId/element": "findElement", + "POST /wd/hub/session/:sessionId/elements": "findElements", + + "POST /wd/hub/session/:sessionId/element/:id/click": "elementClick", + "POST /wd/hub/session/:sessionId/element/:id/clear": "elementClear", + "POST /wd/hub/session/:sessionId/element/:id/value": "elementSendKeys", + + "GET /wd/hub/session/:sessionId/source": "getPageSource", + "GET /wd/hub/session/:sessionId/url": "getCurrentUrl", + "POST /wd/hub/session/:sessionId/url": "navigateTo", + + "POST /wd/hub/session/:sessionId/execute": "executeScript", +}; + +const AppiumCommandMap: Record = { + "POST /session/:sessionId/appium/device/lock": "lockDevice", + "POST /session/:sessionId/appium/device/unlock": "unlockDevice", + "GET /session/:sessionId/appium/device/time": "getDeviceTime", + "POST /session/:sessionId/appium/app/launch": "launchApp", + "POST /session/:sessionId/appium/app/close": "closeApp", + "POST /session/:sessionId/appium/device/press_keycode": "pressKeyCode", + "POST /session/:sessionId/appium/device/long_press_keycode": "longPressKeyCode", + "POST /session/:sessionId/appium/device/touch_id": "touchId", + "POST /session/:sessionId/appium/device/shake": "shakeDevice", + "POST /session/:sessionId/appium/device/hide_keyboard": "hideKeyboard", + "POST /session/:sessionId/appium/device/is_keyboard_shown": "isKeyboardShown", +}; + +const CommandMap = { + ...WebDriverCommandMap, + ...JSONWireCommandMap, + ...AppiumCommandMap, +}; + +const resolveCommandName = (method: string, endpoint: string): string => { + const normalized = `${method.toUpperCase()} ${endpoint}`; + + // exact match first + if (CommandMap[normalized]) return CommandMap[normalized]; + + // pattern match + for (const pattern of Object.keys(CommandMap)) { + const [pMethod, pPath] = pattern.split(" "); + if (pMethod !== method.toUpperCase()) continue; + + const regex = new RegExp( + "^" + + pPath + .replace(/\//g, "\\/") + .replace(/:sessionId/g, "[^/]+") + .replace(/:id/g, "[^/]+") + .replace(/:name/g, "[^/]+") + .replace(/:propertyName/g, "[^/]+") + + "$" + ); + + if (regex.test(endpoint)) { + return CommandMap[pattern]; + } + } + + return "unknownCommand"; +}; + + export const parseAutomateTextLogs = (lines: string[]): ParsedTextLogsResult => { const capabilities: string[] = []; const requests: (ParsedTextLogsRequest | string)[] = []; @@ -15,7 +154,7 @@ export const parseAutomateTextLogs = (lines: string[]): ParsedTextLogsResult => if ([1, 2, 3].includes(i)) return; if (i === 0) { - capabilities.push(buildCapabilities(line.split("/session"),false)); + capabilities.push(buildCapabilities(line.split("/session"), false)); return; } @@ -28,12 +167,13 @@ export const parseAutomateTextLogs = (lines: string[]): ParsedTextLogsResult => const sleepTime = Math.floor((timeRequest - timeLastResponse) / 1000); if (sleepTime > 0) requests.push(`SLEEP ${sleepTime}`); } - const method = parts[5]; const endpoint = "/" + parts[6].split("/").slice(3).join("/"); + const commandName = resolveCommandName(method, parts[6]) let data: Record | string = {}; try { + console.log(parts.slice(7).join(" ")); const parsed = JSON.parse(parts.slice(7).join(" ")); data = parsed || {}; } catch (err: any) { @@ -41,7 +181,7 @@ export const parseAutomateTextLogs = (lines: string[]): ParsedTextLogsResult => data = {}; } - requests.push({ method, endpoint, data }); + requests.push({ method, endpoint, data, commandName }); } else if (requestType === "RESPONSE") { try { const json = JSON.parse(parts.slice(3).join(" ")); @@ -86,23 +226,27 @@ export const parseAppAutomateTextLogs = (lines: string[]): ParsedTextLogsResult } if (!startSessionReceived) { - capabilities.push(buildCapabilities(rawLine.split("/session"),true)); + capabilities.push(buildCapabilities(rawLine.split("/session"), true)); return; } const method = parts[5]; const endpoint = "/" + parts[6].split("/").slice(3).join("/"); + const commandName = resolveCommandName(method, parts[6]) let data: Record | string = {}; + console.log(rawLine) try { - const parsed = JSON.parse(parts.slice(7).join(" ")); + let raw = parts.slice(7).join(" "); + raw = fixEmbeddedJson(raw); + const parsed = JSON.parse(raw); data = parsed || {}; } catch (err: any) { console.log(err.message); data = {}; } - requests.push({ method, endpoint, data }); + requests.push({ method, endpoint, data, commandName }); } else if (requestType === "RESPONSE") { let responseVal: unknown = ""; try { @@ -117,3 +261,22 @@ export const parseAppAutomateTextLogs = (lines: string[]): ParsedTextLogsResult return { capabilities, requests, responses }; }; + +function fixEmbeddedJson(input: string): string { + return input.replace( + /"([^"]+)":"([^"]*{[^"]*}[^"]*)"/g, // matches key: " value-with-{...} " + (fullMatch, key, value) => { + try { + // detect all { ... } blocks inside the string + return fullMatch.replace(/\{[^{}]*\}/g, (jsonBlock) => { + // try parsing the block + const parsed = JSON.parse(jsonBlock); + // re-stringify the block so quotes escape correctly + return JSON.stringify(parsed); + }); + } catch { + return fullMatch; // leave unchanged if not valid JSON + } + } + ); +} \ No newline at end of file