Skip to content

Commit c9a1b65

Browse files
committed
fix(regression-test): stabilize platform integrations
Signed-off-by: kangfenmao <kangfenmao@qq.com>
1 parent ad9e6da commit c9a1b65

6 files changed

Lines changed: 81 additions & 90 deletions

File tree

scripts/cherry-regression-test/__tests__/lifecycle.test.ts

Lines changed: 31 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ afterEach(() => {
3232
vi.unstubAllGlobals()
3333
})
3434

35+
function mockMainInspector(): void {
36+
vi.stubGlobal(
37+
'fetch',
38+
vi.fn().mockResolvedValue({
39+
json: async () => [{ type: 'node', webSocketDebuggerUrl: 'ws://127.0.0.1:9229/main-process' }],
40+
ok: true,
41+
status: 200
42+
})
43+
)
44+
evaluateCdpExpressionMock.mockResolvedValue(true)
45+
}
46+
3547
describe('owned application lifecycle', () => {
3648
it('reuses a live application when the requested profile already matches', async () => {
3749
const directory = mkdtempSync(join(tmpdir(), 'cherry-regression-lifecycle-'))
@@ -131,7 +143,7 @@ describe('owned application lifecycle', () => {
131143
}
132144
})
133145

134-
it('delivers a protocol URL through the owned Windows development instance', async () => {
146+
it('delivers a protocol URL through the owned Windows main-process inspector', async () => {
135147
const electronPid = 42_001
136148
const targetRoot = 'D:\\target-app'
137149
const executablePath = `${targetRoot}\\node_modules\\electron\\dist\\electron.exe`
@@ -157,27 +169,21 @@ describe('owned application lifecycle', () => {
157169
}
158170
const callback = 'cherrystudio://oauth/callback?code=test-code&state=test-state'
159171
vi.spyOn(process, 'kill').mockReturnValue(true)
172+
mockMainInspector()
160173
execFileSyncMock.mockImplementation((file: string, args: string[]) => {
161174
const script = String(args.at(-1))
162-
if (file === executablePath) return ''
163175
if (script.includes('Get-NetTCPConnection')) return String(electronPid)
164176
if (script.includes('CommandLine')) return `${executablePath} ${targetRoot}`
165-
if (script.includes('ExecutablePath')) return executablePath
166177
throw new Error(`Unexpected command: ${file} ${args.join(' ')}`)
167178
})
168179

169180
await sendProtocolUrlToOwnedApp(record, callback)
170181

171-
expect(execFileSyncMock).toHaveBeenCalledWith(
172-
executablePath,
173-
[targetRoot, callback],
174-
expect.objectContaining({
175-
cwd: targetRoot,
176-
env: expect.objectContaining({ CS_DEV_USER_DATA_SUFFIX: 'Regression-test-run-authenticated' }),
177-
stdio: 'ignore',
178-
windowsHide: true
179-
})
182+
expect(evaluateCdpExpressionMock).toHaveBeenCalledWith(
183+
'ws://127.0.0.1:9229/main-process',
184+
expect.stringContaining("electron.app.emit('second-instance'")
180185
)
186+
expect(evaluateCdpExpressionMock.mock.calls[0][1]).toContain(callback)
181187
})
182188

183189
it('disposes non-main windows before a Windows CDP connection', async () => {
@@ -233,7 +239,7 @@ describe('owned application lifecycle', () => {
233239
expect(evaluateCdpExpressionMock.mock.calls[0][1]).toContain('window.destroy()')
234240
})
235241

236-
it('delivers a protocol URL through the owned macOS development instance', async () => {
242+
it('delivers a protocol URL through the owned macOS main-process inspector', async () => {
237243
const electronPid = 42_001
238244
const targetRoot = '/tmp/target-app'
239245
const executablePath = `${targetRoot}/node_modules/electron/dist/Electron.app/Contents/MacOS/Electron`
@@ -259,27 +265,24 @@ describe('owned application lifecycle', () => {
259265
}
260266
const callback = 'cherrystudio://oauth/callback?code=test-code&state=test-state'
261267
vi.spyOn(process, 'kill').mockReturnValue(true)
268+
mockMainInspector()
262269
execFileSyncMock.mockImplementation((file: string, args: string[]) => {
263-
if (file === 'open') return ''
264-
if (file === 'lsof') return String(electronPid)
270+
if (file === 'lsof' && args.includes('-iTCP:9222')) return String(electronPid)
271+
if (file === 'lsof' && args.includes('-iTCP:9229')) return String(electronPid)
265272
if (file === 'ps' && args.includes('command=')) return `${executablePath} ${targetRoot}`
266-
if (file === 'ps' && args.includes('comm=')) return executablePath
267273
throw new Error(`Unexpected command: ${file} ${args.join(' ')}`)
268274
})
269275

270276
await sendProtocolUrlToOwnedApp(record, callback)
271277

272-
expect(execFileSyncMock).toHaveBeenCalledWith(
273-
'open',
274-
['-a', `${targetRoot}/node_modules/electron/dist/Electron.app`, callback],
275-
expect.objectContaining({
276-
cwd: targetRoot,
277-
stdio: 'ignore'
278-
})
278+
expect(evaluateCdpExpressionMock).toHaveBeenCalledWith(
279+
'ws://127.0.0.1:9229/main-process',
280+
expect.stringContaining("electron.app.emit('open-url'")
279281
)
282+
expect(evaluateCdpExpressionMock.mock.calls[0][1]).toContain(callback)
280283
})
281284

282-
it('rejects a development executable outside the owned target root', async () => {
285+
it('rejects a main-process inspector owned by another process', async () => {
283286
const electronPid = 42_001
284287
const targetRoot = '/tmp/target-app'
285288
const record: AppRecord = {
@@ -304,15 +307,16 @@ describe('owned application lifecycle', () => {
304307
}
305308
vi.spyOn(process, 'kill').mockReturnValue(true)
306309
execFileSyncMock.mockImplementation((file: string, args: string[]) => {
307-
if (file === 'lsof') return String(electronPid)
310+
if (file === 'lsof' && args.includes('-iTCP:9222')) return String(electronPid)
311+
if (file === 'lsof' && args.includes('-iTCP:9229')) return '99999'
308312
if (file === 'ps' && args.includes('command='))
309313
return `${targetRoot}/node_modules/electron/Electron ${targetRoot}`
310-
if (file === 'ps' && args.includes('comm=')) return '/tmp/other-app/Electron'
311314
throw new Error(`Unexpected command: ${file} ${args.join(' ')}`)
312315
})
313316

314317
await expect(
315318
sendProtocolUrlToOwnedApp(record, 'cherrystudio://oauth/callback?code=test-code&state=test-state')
316-
).rejects.toThrow('Owned macOS Electron executable could not be verified')
319+
).rejects.toThrow('does not own the main-process inspector')
320+
expect(evaluateCdpExpressionMock).not.toHaveBeenCalled()
317321
})
318322
})

scripts/cherry-regression-test/lifecycle.ts

Lines changed: 28 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -103,14 +103,6 @@ function windowsProcessExecutablePath(pid: number): string {
103103
}
104104
}
105105

106-
function macosProcessExecutablePath(pid: number): string {
107-
try {
108-
return execFileSync('ps', ['-o', 'comm=', '-p', String(pid)], { encoding: 'utf8', timeout: 10_000 }).trim()
109-
} catch {
110-
return ''
111-
}
112-
}
113-
114106
function assertOwnedProcess(record: AppRecord, pid: number, kind: 'electron' | 'runner'): void {
115107
const command = processCommand(pid, record.platform)
116108
const expected =
@@ -489,14 +481,29 @@ export async function prepareWindowsCdpConnection(record: AppRecord): Promise<vo
489481
export async function sendProtocolUrlToOwnedApp(record: AppRecord, url: string): Promise<void> {
490482
if (!isAlive(record.electronPid)) throw new Error('Owned Cherry Studio instance is not running')
491483
assertOwnedProcess(record, record.electronPid, 'electron')
484+
if (record.mode === 'branch') {
485+
const debuggerUrl = await ownedMainInspectorUrl(record)
486+
const delivered = await evaluateCdpExpression<boolean>(
487+
debuggerUrl,
488+
`(() => {
489+
const electron = process.mainModule?.require?.('electron')
490+
if (!electron?.app) throw new Error('Electron app is unavailable')
491+
const callbackUrl = ${JSON.stringify(url)}
492+
if (${JSON.stringify(record.platform)} === 'macos') {
493+
return electron.app.emit('open-url', { preventDefault() {} }, callbackUrl)
494+
}
495+
return electron.app.emit('second-instance', {}, [process.execPath, process.argv[1] ?? '', callbackUrl], process.cwd())
496+
})()`
497+
)
498+
if (!delivered) throw new Error('Owned Cherry Studio instance has no protocol URL listener')
499+
return
500+
}
492501

493502
if (record.platform === 'macos') {
494-
const executablePath =
495-
record.mode === 'branch' ? macosProcessExecutablePath(record.electronPid) : record.executablePath
496-
const isVerifiedExecutable =
497-
record.mode === 'branch'
498-
? basename(executablePath ?? '') === 'Electron' && isPathInside(record.targetRoot, executablePath ?? '')
499-
: Boolean(record.executablePath && resolve(executablePath ?? '') === resolve(record.executablePath))
503+
const executablePath = record.executablePath
504+
const isVerifiedExecutable = Boolean(
505+
record.executablePath && resolve(executablePath ?? '') === resolve(record.executablePath)
506+
)
500507
if (!executablePath || !isVerifiedExecutable) {
501508
throw new Error('Owned macOS Electron executable could not be verified')
502509
}
@@ -517,32 +524,19 @@ export async function sendProtocolUrlToOwnedApp(record: AppRecord, url: string):
517524
}
518525

519526
const executablePath = windowsProcessExecutablePath(record.electronPid)
520-
const relativeExecutable = win32.relative(win32.resolve(record.targetRoot), win32.resolve(executablePath))
521-
const isVerifiedExecutable =
522-
record.mode === 'branch'
523-
? win32.basename(executablePath).toLowerCase() === 'electron.exe' &&
524-
!relativeExecutable.startsWith('..') &&
525-
!win32.isAbsolute(relativeExecutable)
526-
: Boolean(
527-
record.executablePath &&
528-
win32.resolve(executablePath).toLowerCase() === win32.resolve(record.executablePath).toLowerCase()
529-
)
527+
const isVerifiedExecutable = Boolean(
528+
record.executablePath &&
529+
win32.resolve(executablePath).toLowerCase() === win32.resolve(record.executablePath).toLowerCase()
530+
)
530531
if (!executablePath || !isVerifiedExecutable) {
531532
throw new Error('Owned Windows Electron executable could not be verified')
532533
}
533534
const userDataArgument = record.args.find((arg) => arg.startsWith('--user-data-dir='))
534-
if (record.mode === 'tag' && !userDataArgument) throw new Error('Owned Windows application profile is missing')
535-
const args =
536-
record.mode === 'branch' ? [record.targetRoot, url] : ([userDataArgument, url].filter(Boolean) as string[])
535+
if (!userDataArgument) throw new Error('Owned Windows application profile is missing')
537536
try {
538-
execFileSync(executablePath, args, {
537+
execFileSync(executablePath, [userDataArgument, url], {
539538
cwd: record.cwd,
540-
env: {
541-
...process.env,
542-
...(record.mode === 'branch'
543-
? { CS_DEV_USER_DATA_SUFFIX: `Regression-${record.runKey}-${record.profile}` }
544-
: {})
545-
},
539+
env: { ...process.env },
546540
stdio: 'ignore',
547541
timeout: 15_000,
548542
windowsHide: true

scripts/cherry-regression-test/system-automation.ts

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,14 @@ function runWindowsHotkey(keys: string[]): void {
5858
const script = [
5959
`Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class NativeKeyboard { [DllImport("user32.dll")] public static extern void keybd_event(byte key, byte scan, uint flags, UIntPtr extra); }'`,
6060
...modifiers.map((modifier) => `[NativeKeyboard]::keybd_event(${virtualKeys[modifier]}, 0, 0, [UIntPtr]::Zero)`),
61+
'Start-Sleep -Milliseconds 100',
6162
`[NativeKeyboard]::keybd_event(${keyCode}, 0, 0, [UIntPtr]::Zero)`,
63+
'Start-Sleep -Milliseconds 100',
6264
`[NativeKeyboard]::keybd_event(${keyCode}, 0, 2, [UIntPtr]::Zero)`,
6365
...modifiers
6466
.toReversed()
65-
.map((modifier) => `[NativeKeyboard]::keybd_event(${virtualKeys[modifier]}, 0, 2, [UIntPtr]::Zero)`)
67+
.map((modifier) => `[NativeKeyboard]::keybd_event(${virtualKeys[modifier]}, 0, 2, [UIntPtr]::Zero)`),
68+
'Start-Sleep -Milliseconds 500'
6669
].join('; ')
6770
execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
6871
stdio: 'ignore',
@@ -102,7 +105,8 @@ export function openExternalText(platform: Platform, paths: RunPaths, candidateP
102105
'$shell = New-Object -ComObject WScript.Shell',
103106
'if (-not $shell.AppActivate($process.Id)) { throw "Notepad window could not be activated" }',
104107
'Start-Sleep -Milliseconds 500',
105-
'$shell.SendKeys("^a")'
108+
'$shell.SendKeys("^a")',
109+
'Start-Sleep -Milliseconds 500'
106110
].join('\n')
107111
execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
108112
stdio: ['ignore', 'ignore', 'pipe'],
@@ -138,8 +142,11 @@ export function chooseNativeFile(platform: Platform, paths: RunPaths, candidateP
138142
} else {
139143
const isDirectory = statSync(filePath).isDirectory()
140144
const script = [
145+
'Set-StrictMode -Version Latest',
146+
'$ErrorActionPreference = "Stop"',
141147
'Add-Type -AssemblyName UIAutomationClient',
142148
'Add-Type -AssemblyName UIAutomationTypes',
149+
'Add-Type -AssemblyName System.Windows.Forms',
143150
'$root = [System.Windows.Automation.AutomationElement]::RootElement',
144151
`$processCondition = [System.Windows.Automation.PropertyCondition]::new([System.Windows.Automation.AutomationElement]::ProcessIdProperty, ${electronPid})`,
145152
'$deadline = [DateTime]::UtcNow.AddSeconds(10)',
@@ -155,33 +162,26 @@ export function chooseNativeFile(platform: Platform, paths: RunPaths, candidateP
155162
'Start-Sleep -Milliseconds 300',
156163
...(isDirectory
157164
? [
158-
'Add-Type -AssemblyName System.Windows.Forms',
159165
'[System.Windows.Forms.SendKeys]::SendWait("^l")',
160166
'Start-Sleep -Milliseconds 200',
161-
'$pathInput = [System.Windows.Automation.AutomationElement]::FocusedElement',
162-
'$valuePattern = $pathInput.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern)',
163-
`$valuePattern.SetValue('${escapePowerShell(filePath)}')`,
167+
`[System.Windows.Forms.SendKeys]::SendWait('${escapePowerShell(filePath)}')`,
164168
'[System.Windows.Forms.SendKeys]::SendWait("{ENTER}")',
165169
'Start-Sleep -Milliseconds 500',
166170
'$windows = $root.FindAll([System.Windows.Automation.TreeScope]::Children, $processCondition)',
167171
'$dialog = $windows | Where-Object { $_.Current.ClassName -eq "#32770" } | Select-Object -Last 1',
168172
'if (-not $dialog) { throw "Native folder dialog closed before selection" }',
169173
'$buttonCondition = [System.Windows.Automation.PropertyCondition]::new([System.Windows.Automation.AutomationElement]::ControlTypeProperty, [System.Windows.Automation.ControlType]::Button)',
170174
'$buttons = $dialog.FindAll([System.Windows.Automation.TreeScope]::Descendants, $buttonCondition)',
171-
'$selectButton = $buttons | Where-Object { $_.Current.Name -in @("Select Folder", "Choose Folder", "Choose this folder", "Select") } | Select-Object -First 1',
175+
'$selectButton = $buttons | Where-Object { $_.Current.Name -in @("Select Folder", "Choose Folder", "Choose this folder", "Select a folder", "Select") } | Select-Object -First 1',
172176
'if (-not $selectButton) { throw "Select Folder button was not found" }',
173-
'$selectButton.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke()'
177+
'$selectButton.SetFocus()',
178+
'[System.Windows.Forms.SendKeys]::SendWait("{ENTER}")'
174179
]
175180
: [
176-
'$fileNameCondition = [System.Windows.Automation.PropertyCondition]::new([System.Windows.Automation.AutomationElement]::AutomationIdProperty, "1148")',
177-
'$fileNameInput = $dialog.FindFirst([System.Windows.Automation.TreeScope]::Descendants, $fileNameCondition)',
178-
'if (-not $fileNameInput) { throw "File name input was not found" }',
179-
'$valuePattern = $fileNameInput.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern)',
180-
`$valuePattern.SetValue('${escapePowerShell(filePath)}')`,
181-
'$openCondition = [System.Windows.Automation.PropertyCondition]::new([System.Windows.Automation.AutomationElement]::NameProperty, "Open")',
182-
'$openButton = $dialog.FindFirst([System.Windows.Automation.TreeScope]::Descendants, $openCondition)',
183-
'if (-not $openButton) { throw "Open button was not found" }',
184-
'$openButton.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke()'
181+
'[System.Windows.Forms.SendKeys]::SendWait("%n")',
182+
'Start-Sleep -Milliseconds 200',
183+
`[System.Windows.Forms.SendKeys]::SendWait('${escapePowerShell(filePath)}')`,
184+
'[System.Windows.Forms.SendKeys]::SendWait("{ENTER}")'
185185
])
186186
].join('\n')
187187
execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {

tests/e2e/cherry-regression/05-desktop-assistants.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ test('[C-02] 使用快捷助手完成全局问答 @quick-assistant', async ({ ap
6464
await invokeQuickAssistant(app, prompt)
6565
page = await app.restart('authenticated')
6666
await dismissOnboarding(page)
67+
await page.waitForTimeout(2_000)
6768
await invokeQuickAssistant(app, prompt)
6869
})
6970

tests/e2e/cherry-regression/06-knowledge.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,6 @@ test('[K-02] 基于知识库问答并验证引用 @knowledge-qa', async ({ app,
5858
await page.keyboard.press('Escape')
5959
await page.getByRole('button', { name: 'Send', exact: true }).click()
6060
await expect(page.locator('body')).toContainText('CHERRY_KNOWLEDGE_58597', { timeout: 2 * 60_000 })
61-
await page.getByText('ground-truth.txt', { exact: true }).last().click()
62-
await expect(page.locator('body')).toContainText('CHERRY_KNOWLEDGE_58597')
61+
await expect(page.locator('body')).toContainText('ground-truth.txt')
62+
await expect(page.getByText(/\d+ citations?/i)).toBeVisible()
6363
})

tests/e2e/cherry-regression/08-code-tools.test.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -101,15 +101,7 @@ test('[CODE-03] 启动 OpenClaw @openclaw', async ({ app, mainWindow: page }) =>
101101
.poll(() => observeOwnedProcess(app.record, 'openclaw', true, baseline).passed, { timeout: 2 * 60_000 })
102102
.toBe(true)
103103

104-
await page
105-
.getByRole('banner')
106-
.getByRole('button', { name: 'Code Mate', exact: true })
107-
.last()
108-
.click({ noWaitAfter: true })
109-
await expect(codeView).toBeVisible()
110-
const stop = codeView.getByRole('button', { name: 'Stop', exact: true })
111-
await expect(stop).toBeVisible()
112-
await stop.click()
113-
await expect(codeView.getByRole('button', { name: 'Launch', exact: true })).toBeVisible({ timeout: 60_000 })
104+
const openClawTab = page.getByRole('banner').getByRole('button', { name: 'OpenClaw', exact: true })
105+
await openClawTab.getByRole('button', { name: 'Close Tab', exact: true }).click()
114106
await expect.poll(() => observeOwnedProcess(app.record, 'openclaw', false).passed, { timeout: 60_000 }).toBe(true)
115107
})

0 commit comments

Comments
 (0)