Skip to content

Commit 1ab1eaa

Browse files
authored
fix(browser): Extension doesn't load when starting a recording in Chrome (#742)
1 parent 7d6f167 commit 1ab1eaa

File tree

7 files changed

+186
-48
lines changed

7 files changed

+186
-48
lines changed

package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@
110110
"clsx": "^2.1.1",
111111
"constrained-editor-plugin": "^1.3.0",
112112
"country-flag-icons": "^1.5.18",
113+
"devtools-protocol": "^0.0.1468520",
113114
"diff": "^7.0.0",
114115
"electron-log": "^5.2.0",
115116
"electron-squirrel-startup": "^1.0.1",

src/handlers/browser/index.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,22 +25,11 @@ export function initialize(browserServer: BrowserServer) {
2525
}
2626
)
2727

28-
ipcMain.on(BrowserHandler.Stop, async () => {
28+
ipcMain.on(BrowserHandler.Stop, () => {
2929
console.info(`${BrowserHandler.Stop} event received`)
3030

31-
const { currentBrowserProcess } = k6StudioState
32-
33-
if (currentBrowserProcess) {
34-
// macOS & windows
35-
if ('close' in currentBrowserProcess) {
36-
await currentBrowserProcess.close()
37-
// linux
38-
} else {
39-
currentBrowserProcess.kill()
40-
}
41-
42-
k6StudioState.currentBrowserProcess = null
43-
}
31+
k6StudioState.currentBrowserProcess?.kill()
32+
k6StudioState.currentBrowserProcess = null
4433
})
4534

4635
ipcMain.handle(BrowserHandler.OpenExternalLink, (_, url: string) => {

src/handlers/browser/launch.ts

Lines changed: 37 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import {
22
computeSystemExecutablePath,
33
Browser,
44
ChromeReleaseChannel,
5-
launch,
65
} from '@puppeteer/browsers'
76
import { exec, spawn } from 'child_process'
87
import { app, BrowserWindow } from 'electron'
@@ -13,6 +12,7 @@ import path from 'path'
1312
import { promisify } from 'util'
1413

1514
import { getCertificateSPKI } from '@/main/proxy'
15+
import { ChromeDevtoolsClient } from '@/utils/cdp/client'
1616

1717
import { BrowserServer } from '../../services/browser/server'
1818
import { getPlatform } from '../../utils/electron'
@@ -52,6 +52,18 @@ function getExtensionPath() {
5252
return path.join(process.resourcesPath, 'extension')
5353
}
5454

55+
const FEATURES_TO_DISABLE = [
56+
'OptimizationGuideModelDownloading',
57+
'OptimizationHintsFetching',
58+
'OptimizationTargetPrediction',
59+
'OptimizationHints',
60+
]
61+
62+
const BROWSER_RECORDING_ARGS = [
63+
'--remote-debugging-pipe',
64+
'--enable-unsafe-extension-debugging',
65+
]
66+
5567
export const launchBrowser = async (
5668
browserWindow: BrowserWindow,
5769
browserServer: BrowserServer,
@@ -64,21 +76,9 @@ export const launchBrowser = async (
6476
console.log(userDataDir)
6577
const certificateSPKI = await getCertificateSPKI()
6678

67-
const optimizationsToDisable = [
68-
'OptimizationGuideModelDownloading',
69-
'OptimizationHintsFetching',
70-
'OptimizationTargetPrediction',
71-
'OptimizationHints',
72-
]
73-
const disableChromeOptimizations = `--disable-features=${optimizationsToDisable.join(',')}`
74-
7579
const extensionPath = getExtensionPath()
7680
console.info(`extension path: ${extensionPath}`)
7781

78-
if (capture.browser) {
79-
browserServer.start(browserWindow)
80-
}
81-
8282
const handleBrowserClose = (): Promise<void> => {
8383
browserServer.stop()
8484

@@ -95,9 +95,7 @@ export const launchBrowser = async (
9595
browserWindow.webContents.send(BrowserHandler.Failed)
9696
}
9797

98-
const browserRecordingArgs = capture.browser
99-
? [`--load-extension=${extensionPath}`]
100-
: []
98+
const browserRecordingArgs = capture.browser ? BROWSER_RECORDING_ARGS : []
10199

102100
const args = [
103101
'--new',
@@ -112,28 +110,36 @@ export const launchBrowser = async (
112110
'--disable-search-engine-choice-screen',
113111
`--proxy-server=http://localhost:${k6StudioState.appSettings.proxy.port}`,
114112
`--ignore-certificate-errors-spki-list=${certificateSPKI}`,
113+
`--disable-features=${FEATURES_TO_DISABLE.join(',')}`,
115114
...browserRecordingArgs,
116-
disableChromeOptimizations,
117115
url?.trim() || 'about:blank',
118116
]
119117

120-
// if we are on linux we spawn the browser directly and attach the on exit callback
121-
if (getPlatform() === 'linux') {
122-
const browserProc = spawn(path, args)
118+
const process = spawn(path, args, {
119+
stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'],
120+
})
123121

124-
browserProc.on('error', handleBrowserLaunchError)
125-
browserProc.once('exit', handleBrowserClose)
126-
return browserProc
122+
if (capture.browser) {
123+
browserServer.start(browserWindow)
124+
125+
const client = ChromeDevtoolsClient.fromChildProcess(process)
126+
127+
process.on('spawn', async () => {
128+
const response = await client.call({
129+
method: 'Extensions.loadUnpacked',
130+
params: {
131+
path: extensionPath,
132+
},
133+
})
134+
135+
console.log(`k6 Studio extension loaded`, response)
136+
})
127137
}
128138

129-
// macOS & windows
130-
const browserProc = launch({
131-
executablePath: path,
132-
args: args,
133-
onExit: handleBrowserClose,
134-
})
135-
browserProc.nodeProcess.on('error', handleBrowserLaunchError)
136-
return browserProc
139+
process.on('error', handleBrowserLaunchError)
140+
process.once('exit', handleBrowserClose)
141+
142+
return process
137143
}
138144

139145
function getChromePath() {

src/main/k6StudioState.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { Process } from '@puppeteer/browsers'
2-
import { ChildProcessWithoutNullStreams } from 'child_process'
1+
import { ChildProcess } from 'child_process'
32
import { FSWatcher } from 'chokidar'
43
import { BrowserWindow } from 'electron'
54
import eventEmitter from 'events'
@@ -11,7 +10,7 @@ import { type ProxyProcess } from './proxy'
1110
import { defaultSettings } from './settings'
1211

1312
export type k6StudioState = {
14-
currentBrowserProcess: Process | ChildProcessWithoutNullStreams | null
13+
currentBrowserProcess: ChildProcess | null
1514
proxyStatus: ProxyStatus
1615
proxyEmitter: eventEmitter
1716
appSettings: AppSettings

src/utils/cdp/client.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { ChildProcess } from 'child_process'
2+
import { Readable, Writable } from 'stream'
3+
4+
import {
5+
ChromeRequest,
6+
ChromeRequestMap,
7+
ChromeResponse,
8+
ChromeResponseSchema,
9+
} from './types'
10+
11+
export class ChromeDevtoolsClient {
12+
static fromChildProcess(process: ChildProcess): ChromeDevtoolsClient {
13+
const send = process.stdio[3]
14+
const receive = process.stdio[4]
15+
16+
if (send instanceof Writable === false) {
17+
throw new Error(
18+
'File descriptor 3 must be writable to send commands over CDP to the browser.'
19+
)
20+
}
21+
22+
if (receive instanceof Readable === false) {
23+
throw new Error(
24+
'File descriptor 4 must be readable to receive responses from CDP in the browser.'
25+
)
26+
}
27+
28+
return new ChromeDevtoolsClient(send, receive)
29+
}
30+
31+
#id: number = 0
32+
33+
#buffer: string = ''
34+
35+
#send: Writable
36+
#receive: Readable
37+
38+
#pending = new Map<number, PromiseWithResolvers<unknown>>()
39+
40+
constructor(send: Writable, receive: Readable) {
41+
this.#send = send
42+
this.#receive = receive
43+
44+
this.#receive.on('data', (data) => {
45+
const string = String(data)
46+
47+
const [first = '', rest] = string.split('\u0000')
48+
49+
this.#buffer += first
50+
51+
if (rest === undefined) {
52+
return
53+
}
54+
55+
const response = JSON.parse(this.#buffer) as unknown
56+
const parsed = ChromeResponseSchema.safeParse(response)
57+
58+
this.#buffer = rest
59+
60+
if (!parsed.success) {
61+
console.error(
62+
'Failed to parse response from browser:',
63+
response,
64+
parsed.error
65+
)
66+
67+
return
68+
}
69+
70+
const resolver = this.#pending.get(parsed.data.id)
71+
72+
if (resolver === undefined) {
73+
return
74+
}
75+
76+
if ('error' in parsed.data) {
77+
resolver.reject(parsed.data.error)
78+
79+
return
80+
}
81+
82+
resolver.resolve(parsed.data.result)
83+
})
84+
}
85+
86+
call<K extends keyof ChromeRequestMap>(
87+
command: ChromeRequest<K>
88+
): Promise<ChromeResponse<K>> {
89+
const resolvers = Promise.withResolvers<ChromeResponse<K>>()
90+
91+
const id = ++this.#id
92+
93+
this.#pending.set(id, resolvers as PromiseWithResolvers<unknown>)
94+
95+
const data = JSON.stringify({
96+
...command,
97+
id,
98+
})
99+
100+
this.#send.write(data + '\u0000')
101+
102+
return resolvers.promise
103+
}
104+
}

src/utils/cdp/types.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Protocol as CDP } from 'devtools-protocol'
2+
import { z } from 'zod'
3+
4+
const ChromeResultSchema = z.object({
5+
id: z.number(),
6+
result: z.record(z.unknown()),
7+
})
8+
9+
const ChromeErrorSchema = z.object({
10+
id: z.number(),
11+
error: z.record(z.unknown()),
12+
})
13+
14+
export const ChromeResponseSchema = z.union([
15+
ChromeResultSchema,
16+
ChromeErrorSchema,
17+
])
18+
19+
export interface ChromeRequestMap {
20+
'Extensions.loadUnpacked': {
21+
request: CDP.Extensions.LoadUnpackedRequest
22+
response: CDP.Extensions.LoadUnpackedResponse
23+
}
24+
}
25+
26+
export type ChromeRequest<K extends keyof ChromeRequestMap> = {
27+
method: K
28+
params: ChromeRequestMap[K]['request']
29+
}
30+
31+
export type ChromeResponse<K extends keyof ChromeRequestMap> =
32+
ChromeRequestMap[K]['response']

0 commit comments

Comments
 (0)