Skip to content

Commit 3eeb458

Browse files
Feat: Implement live browser session management with desktop and mobile support
1 parent 29f06b2 commit 3eeb458

File tree

5 files changed

+444
-206
lines changed

5 files changed

+444
-206
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import fs from "fs";
2+
import os from "os";
3+
import path from "path";
4+
5+
const CACHE_DIR = path.join(os.homedir(), ".browserstack", "live_cache");
6+
const CACHE_FILE = path.join(CACHE_DIR, "live.json");
7+
const TTL_MS = 24 * 60 * 60 * 1000; // 1 day
8+
9+
/**
10+
* Fetches and caches the Live browsers/platforms JSON with a 1-day TTL.
11+
*/
12+
export async function getLiveData(): Promise<any> {
13+
if (!fs.existsSync(CACHE_DIR)) {
14+
fs.mkdirSync(CACHE_DIR, { recursive: true });
15+
}
16+
if (fs.existsSync(CACHE_FILE)) {
17+
const stats = fs.statSync(CACHE_FILE);
18+
if (Date.now() - stats.mtimeMs < TTL_MS) {
19+
return JSON.parse(fs.readFileSync(CACHE_FILE, "utf8"));
20+
}
21+
}
22+
23+
const resp = await fetch(
24+
"https://www.browserstack.com/list-of-browsers-and-platforms/live.json",
25+
);
26+
if (!resp.ok) {
27+
throw new Error(`Failed to fetch live list: ${resp.statusText}`);
28+
}
29+
const data = await resp.json();
30+
fs.writeFileSync(CACHE_FILE, JSON.stringify(data), "utf8");
31+
return data;
32+
}
Lines changed: 183 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,204 @@
1+
// File: src/tools/live-utils/start-session.ts
12
import { sanitizeUrlParam } from "../../lib/utils";
23
import logger from "../../logger";
34
import childProcess from "child_process";
5+
import { getLiveData } from "./device-cache";
6+
import { resolveVersion } from "./version-resolver";
7+
import { customFuzzySearch } from "../../lib/fuzzy";
48

5-
interface StartSessionArgs {
6-
browser: string;
9+
export interface DesktopArgs {
10+
platformType: "desktop";
11+
url: string;
712
os: string;
813
osVersion: string;
9-
url: string;
14+
browser: string;
1015
browserVersion: string;
1116
isLocal: boolean;
1217
}
18+
export interface MobileArgs {
19+
platformType: "mobile";
20+
url: string;
21+
os: string;
22+
osVersion: string;
23+
device: string;
24+
browser: string;
25+
isLocal: boolean;
26+
}
1327

28+
/**
29+
* Entrypoint: detects platformType & delegates.
30+
*/
1431
export async function startBrowserSession(
15-
args: StartSessionArgs,
32+
args: DesktopArgs | MobileArgs,
1633
): Promise<string> {
17-
// Sanitize all input parameters
18-
const sanitizedArgs = {
19-
browser: sanitizeUrlParam(args.browser),
20-
os: sanitizeUrlParam(args.os),
21-
osVersion: sanitizeUrlParam(args.osVersion),
34+
if (args.platformType === "desktop") {
35+
const entry = await filterDesktop(args);
36+
const url = buildDesktopUrl(args, entry);
37+
openBrowser(url);
38+
return url;
39+
} else {
40+
const entry = await filterMobile(args);
41+
const url = buildMobileUrl(args, entry);
42+
openBrowser(url);
43+
return url;
44+
}
45+
}
46+
47+
// ——— Desktop ———
48+
49+
interface DesktopEntry {
50+
os: string;
51+
os_version: string;
52+
browser: string;
53+
browser_version: string;
54+
}
55+
56+
async function filterDesktop(args: DesktopArgs): Promise<DesktopEntry> {
57+
const data = await getLiveData();
58+
const all: DesktopEntry[] = data.desktop.flatMap((plat: any) =>
59+
plat.browsers.map((b: any) => ({
60+
os: plat.os,
61+
os_version: plat.os_version,
62+
browser: b.browser,
63+
browser_version: b.browser_version,
64+
})),
65+
);
66+
67+
let entries = all.filter((e) => e.os === args.os);
68+
if (!entries.length) throw new Error(`No OS entries for "${args.os}".`);
69+
70+
entries = entries.filter((e) => e.browser === args.browser);
71+
if (!entries.length)
72+
throw new Error(`No browser "${args.browser}" on ${args.os}.`);
73+
74+
const uniqueVers = Array.from(new Set(entries.map((e) => e.os_version)));
75+
76+
let chosenOS: string;
77+
if (args.os === "OS X") {
78+
// macOS named versions: fuzzy match or pick first/last in JSON order
79+
if (args.osVersion === "latest") {
80+
chosenOS = uniqueVers[uniqueVers.length - 1];
81+
} else if (args.osVersion === "oldest") {
82+
chosenOS = uniqueVers[0];
83+
} else {
84+
// try fuzzy
85+
const fuzzy = customFuzzySearch(
86+
uniqueVers.map((v) => ({ os_version: v })),
87+
["os_version"],
88+
args.osVersion,
89+
1,
90+
);
91+
chosenOS = fuzzy.length ? fuzzy[0].os_version : args.osVersion;
92+
}
93+
// fallback if still not valid
94+
if (!uniqueVers.includes(chosenOS)) {
95+
chosenOS = uniqueVers[0];
96+
}
97+
} else {
98+
// numeric/semantic resolve for Windows
99+
chosenOS = resolveVersion(args.osVersion, uniqueVers);
100+
}
101+
entries = entries.filter((e) => e.os_version === chosenOS);
102+
// resolve browser version
103+
const browVers = entries.map((e) => e.browser_version);
104+
const chosenBrow = resolveVersion(args.browserVersion, browVers);
105+
const final = entries.find((e) => e.browser_version === chosenBrow);
106+
if (!final)
107+
throw new Error(`No entry for browser version "${args.browserVersion}".`);
108+
109+
return final;
110+
}
111+
112+
function buildDesktopUrl(args: DesktopArgs, e: DesktopEntry): string {
113+
const params = new URLSearchParams({
114+
os: sanitizeUrlParam(e.os),
115+
os_version: sanitizeUrlParam(e.os_version),
116+
browser: sanitizeUrlParam(e.browser),
117+
browser_version: sanitizeUrlParam(e.browser_version),
22118
url: sanitizeUrlParam(args.url),
23-
browserVersion: sanitizeUrlParam(args.browserVersion),
24-
isLocal: args.isLocal,
119+
scale_to_fit: "true",
120+
resolution: "responsive-mode",
121+
speed: "1",
122+
local: args.isLocal ? "true" : "false",
123+
start: "true",
124+
});
125+
return `https://live.browserstack.com/dashboard#${params.toString()}`;
126+
}
127+
128+
// ——— Mobile ———
129+
130+
interface MobileEntry {
131+
os: string;
132+
os_version: string;
133+
display_name: string;
134+
}
135+
136+
async function filterMobile(args: MobileArgs): Promise<MobileEntry> {
137+
const data = await getLiveData();
138+
const all: MobileEntry[] = data.mobile.flatMap((grp: any) =>
139+
grp.devices.map((d: any) => ({
140+
os: grp.os,
141+
os_version: d.os_version,
142+
display_name: d.display_name,
143+
})),
144+
);
145+
146+
let candidates = all.filter((d) => d.os === args.os);
147+
if (!candidates.length)
148+
throw new Error(`No mobile OS entries for "${args.os}".`);
149+
150+
// resolve OS version
151+
const vers = candidates.map((d) => d.os_version);
152+
const chosen = resolveVersion(args.osVersion, vers);
153+
candidates = candidates.filter((d) => d.os_version === chosen);
154+
155+
// fuzzy‐match device name
156+
const matches = customFuzzySearch(
157+
candidates,
158+
["display_name"],
159+
args.device,
160+
5,
161+
);
162+
if (!matches.length)
163+
throw new Error(
164+
`No devices matching "${args.device}" on ${args.os} ${chosen}.`,
165+
);
166+
167+
const exact = matches.find(
168+
(m) => m.display_name.toLowerCase() === args.device.toLowerCase(),
169+
);
170+
if (!exact) {
171+
const names = matches.map((m) => m.display_name).join(", ");
172+
throw new Error(`Did you mean: ${names}?`);
173+
}
174+
return exact;
175+
}
176+
177+
function buildMobileUrl(args: MobileArgs, d: MobileEntry): string {
178+
const os_map = {
179+
android: "Android",
180+
ios: "iOS",
181+
winphone: "Winphone",
25182
};
183+
const os = os_map[d.os as keyof typeof os_map] || d.os;
26184

27-
// Construct URL with encoded parameters
28185
const params = new URLSearchParams({
29-
os: sanitizedArgs.os,
30-
os_version: sanitizedArgs.osVersion,
31-
browser: sanitizedArgs.browser,
32-
browser_version: sanitizedArgs.browserVersion,
186+
os: sanitizeUrlParam(os),
187+
os_version: sanitizeUrlParam(d.os_version),
188+
device: d.display_name,
189+
device_browser: sanitizeUrlParam(args.browser),
190+
url: sanitizeUrlParam(args.url),
33191
scale_to_fit: "true",
34-
url: sanitizedArgs.url,
35-
resolution: "responsive-mode",
36192
speed: "1",
37-
local: sanitizedArgs.isLocal ? "true" : "false",
38193
start: "true",
39194
});
195+
return `https://live.browserstack.com/dashboard#${params.toString()}`;
196+
}
40197

41-
const launchUrl = `https://live.browserstack.com/dashboard#${params.toString()}`;
198+
// ——— Open a browser window ———
42199

200+
function openBrowser(launchUrl: string): void {
43201
try {
44-
// Use platform-specific commands with proper escaping
45202
const command =
46203
process.platform === "darwin"
47204
? ["open", launchUrl]
@@ -54,22 +211,11 @@ export async function startBrowserSession(
54211
stdio: "ignore",
55212
detached: true,
56213
});
57-
58-
// Handle process errors
59-
child.on("error", (error) => {
60-
logger.error(
61-
`Failed to open browser automatically: ${error}. Please open this URL manually: ${launchUrl}`,
62-
);
63-
});
64-
65-
// Unref the child process to allow the parent to exit
66-
child.unref();
67-
68-
return launchUrl;
69-
} catch (error) {
70-
logger.error(
71-
`Failed to open browser automatically: ${error}. Please open this URL manually: ${launchUrl}`,
214+
child.on("error", (err) =>
215+
logger.error(`Failed to open browser: ${err}. URL: ${launchUrl}`),
72216
);
73-
return launchUrl;
217+
child.unref();
218+
} catch (err) {
219+
logger.error(`Failed to launch browser: ${err}. URL: ${launchUrl}`);
74220
}
75221
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* If req === "latest" or "oldest", returns max/min numeric (or lex)
3+
* Else if exact match, returns that
4+
* Else picks the numerically closest (or first)
5+
*/
6+
export function resolveVersion(requested: string, available: string[]): string {
7+
// strip duplicates & sort
8+
const uniq = Array.from(new Set(available));
9+
// pick min/max
10+
if (requested === "latest" || requested === "oldest") {
11+
// try numeric
12+
const nums = uniq
13+
.map((v) => ({ v, n: parseFloat(v) }))
14+
.filter((x) => !isNaN(x.n))
15+
.sort((a, b) => a.n - b.n);
16+
if (nums.length) {
17+
return requested === "latest" ? nums[nums.length - 1].v : nums[0].v;
18+
}
19+
// fallback lex
20+
const lex = uniq.slice().sort();
21+
return requested === "latest" ? lex[lex.length - 1] : lex[0];
22+
}
23+
24+
// exact?
25+
if (uniq.includes(requested)) {
26+
return requested;
27+
}
28+
29+
// try closest numeric
30+
const reqNum = parseFloat(requested);
31+
const nums = uniq
32+
.map((v) => ({ v, n: parseFloat(v) }))
33+
.filter((x) => !isNaN(x.n));
34+
if (!isNaN(reqNum) && nums.length) {
35+
let best = nums[0],
36+
bestDiff = Math.abs(nums[0].n - reqNum);
37+
for (const x of nums) {
38+
const d = Math.abs(x.n - reqNum);
39+
if (d < bestDiff) {
40+
best = x;
41+
bestDiff = d;
42+
}
43+
}
44+
return best.v;
45+
}
46+
47+
// final fallback
48+
return uniq[0];
49+
}

0 commit comments

Comments
 (0)