445 lines
17 KiB
JavaScript
445 lines
17 KiB
JavaScript
import { spawn, spawnSync } from 'node:child_process'
|
|
import { createConnection, createServer } from 'node:net'
|
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
import { isPackageE2EInstallPath } from './package-e2e-isolation.mjs'
|
|
|
|
const CONFIG_ENV = 'NCURATOR_E2E_DESKTOP_LAUNCH_CONFIG'
|
|
const SCRIPT_PATH = fileURLToPath(import.meta.url)
|
|
const shellQuote = (value) => `'${value.replaceAll("'", "'\\''")}'`
|
|
const powershellCommand = (script) => [
|
|
'-NoProfile',
|
|
'-NonInteractive',
|
|
'-ExecutionPolicy',
|
|
'Bypass',
|
|
'-EncodedCommand',
|
|
Buffer.from(script, 'utf16le').toString('base64')
|
|
]
|
|
|
|
export const resolveMacApplication = (executablePath) => {
|
|
const application = path.dirname(path.dirname(path.dirname(executablePath)))
|
|
if (!application.endsWith('.app') || path.basename(path.dirname(executablePath)) !== 'MacOS') {
|
|
throw new Error(`Expected an installed macOS application: ${executablePath}`)
|
|
}
|
|
return application
|
|
}
|
|
|
|
export const findInstalledWindowsShortcut = (executablePath) => {
|
|
// Read the installer's shortcuts. Do not construct a replacement with a
|
|
// test-selected working directory.
|
|
const script = String.raw`
|
|
$ErrorActionPreference = 'Stop'
|
|
$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::UTF8
|
|
$target = [IO.Path]::GetFullPath($env:NCURATOR_E2E_SHORTCUT_TARGET)
|
|
$shell = New-Object -ComObject WScript.Shell
|
|
$roots = @(
|
|
[Environment]::GetFolderPath('Desktop'),
|
|
[Environment]::GetFolderPath('CommonDesktopDirectory'),
|
|
[Environment]::GetFolderPath('StartMenu'),
|
|
[Environment]::GetFolderPath('CommonStartMenu')
|
|
)
|
|
foreach ($root in $roots) {
|
|
if (-not (Test-Path -LiteralPath $root)) { continue }
|
|
foreach ($file in Get-ChildItem -LiteralPath $root -Filter '*.lnk' -Recurse -ErrorAction SilentlyContinue) {
|
|
$link = $shell.CreateShortcut($file.FullName)
|
|
if ([string]::Equals($link.TargetPath, $target, [StringComparison]::OrdinalIgnoreCase)) {
|
|
[Console]::WriteLine($file.FullName)
|
|
exit 0
|
|
}
|
|
}
|
|
}
|
|
throw 'The installer did not create a shortcut targeting the E2E installation'
|
|
`
|
|
const result = spawnSync('powershell.exe', powershellCommand(script), {
|
|
env: { ...process.env, NCURATOR_E2E_SHORTCUT_TARGET: executablePath },
|
|
encoding: 'utf8',
|
|
windowsHide: true
|
|
})
|
|
if (result.error) throw result.error
|
|
if (result.status !== 0) throw new Error(result.stderr.trim())
|
|
const shortcut = result.stdout.trim()
|
|
if (!shortcut.endsWith('.lnk')) throw new Error('No installed application shortcut was found')
|
|
return shortcut
|
|
}
|
|
|
|
export const terminateDesktopProcess = (pid) => {
|
|
if (!Number.isInteger(pid) || pid <= 0) return
|
|
if (process.platform === 'win32') {
|
|
spawnSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], {
|
|
stdio: 'ignore',
|
|
windowsHide: true
|
|
})
|
|
return
|
|
}
|
|
const result = spawnSync('/bin/ps', ['-axo', 'pid=,ppid='], { encoding: 'utf8' })
|
|
const rows = String(result.stdout || '')
|
|
.trim()
|
|
.split('\n')
|
|
.map((line) => line.trim().split(/\s+/u).map(Number))
|
|
const descendants = [pid]
|
|
for (let index = 0; index < descendants.length; index++) {
|
|
for (const [child, parent] of rows) {
|
|
if (parent === descendants[index] && !descendants.includes(child)) descendants.push(child)
|
|
}
|
|
}
|
|
for (const target of descendants.reverse()) {
|
|
try {
|
|
process.kill(target, 'SIGKILL')
|
|
} catch {
|
|
/* already exited */
|
|
}
|
|
}
|
|
}
|
|
|
|
export const prepareDesktopLaunch = async ({ executablePath, entryPath, env }) => {
|
|
if (!isPackageE2EInstallPath(executablePath)) {
|
|
throw new Error(`Refusing to launch a production installation in E2E: ${executablePath}`)
|
|
}
|
|
const launchEntry =
|
|
entryPath || (process.platform === 'darwin' ? resolveMacApplication(executablePath) : '')
|
|
if (!launchEntry) throw new Error('Missing installer-created desktop launch entry')
|
|
const directory = await mkdtemp(path.join(os.tmpdir(), 'ncurator-desktop-launch-'))
|
|
const configPath = path.join(directory, 'launch.json')
|
|
const statePath = path.join(directory, 'state.json')
|
|
const disconnectPath = path.join(directory, 'disconnect-debugger')
|
|
const launcherPath = path.join(directory, process.platform === 'win32' ? 'launch.cmd' : 'launch')
|
|
const environment = { ...env }
|
|
delete environment[CONFIG_ENV]
|
|
delete environment.NODE_OPTIONS
|
|
delete environment.ELECTRON_RUN_AS_NODE
|
|
const config = {
|
|
executablePath,
|
|
entryPath: launchEntry,
|
|
directory,
|
|
statePath,
|
|
disconnectPath,
|
|
environment
|
|
}
|
|
await writeFile(configPath, JSON.stringify(config), { mode: 0o600 })
|
|
const command =
|
|
process.platform === 'win32'
|
|
? `@echo off\r\n"${process.execPath.replaceAll('%', '%%')}" "${SCRIPT_PATH.replaceAll('%', '%%')}" %*\r\n`
|
|
: `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(SCRIPT_PATH)} "$@"\n`
|
|
await writeFile(launcherPath, command, { mode: 0o700 })
|
|
await chmod(launcherPath, 0o700)
|
|
const readState = () => {
|
|
try {
|
|
return JSON.parse(readFileSync(statePath, 'utf8'))
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
return {
|
|
executablePath: launcherPath,
|
|
env: { ...env, [CONFIG_ENV]: configPath },
|
|
readState,
|
|
// Release the observer connection after application shutdown has been
|
|
// requested. The inspector must not keep an otherwise exiting app alive.
|
|
releaseDebugger: () => writeFile(disconnectPath, ''),
|
|
dispose: async () => {
|
|
const state = readState()
|
|
if (state?.pid && !state.exited) terminateDesktopProcess(state.pid)
|
|
await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
|
}
|
|
}
|
|
}
|
|
|
|
const reservePort = async () => {
|
|
const server = createServer()
|
|
await new Promise((resolve, reject) => {
|
|
server.once('error', reject)
|
|
server.listen(0, '127.0.0.1', resolve)
|
|
})
|
|
const address = server.address()
|
|
if (!address || typeof address === 'string') throw new Error('Unable to reserve debug port')
|
|
await new Promise((resolve) => server.close(resolve))
|
|
return address.port
|
|
}
|
|
|
|
const MAC_LAUNCH_SCRIPT = String.raw`
|
|
ObjC.import('AppKit');
|
|
ObjC.import('Foundation');
|
|
function run(argv) {
|
|
const data = $.NSData.dataWithContentsOfFile(argv[0]);
|
|
const config = JSON.parse($.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding).js);
|
|
const options = $.NSWorkspaceOpenConfiguration.configuration;
|
|
options.arguments = $(config.arguments);
|
|
options.environment = $(config.environment);
|
|
options.createsNewApplicationInstance = true;
|
|
options.activates = false;
|
|
let completed = false;
|
|
let result;
|
|
$.NSWorkspace.sharedWorkspace.openApplicationAtURLConfigurationCompletionHandler(
|
|
$.NSURL.fileURLWithPath(config.entryPath), options,
|
|
function(application, error) {
|
|
result = error ? {error: ObjC.unwrap(error.localizedDescription)} : {pid: Number(application.processIdentifier)};
|
|
completed = true;
|
|
}
|
|
);
|
|
const deadline = Date.now() + 30000;
|
|
while (!completed && Date.now() < deadline)
|
|
$.NSRunLoop.currentRunLoop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(0.05));
|
|
if (!completed) throw new Error('Launch Services timed out');
|
|
if (result.error) throw new Error(result.error);
|
|
return JSON.stringify(result);
|
|
}
|
|
`
|
|
|
|
const WINDOWS_LAUNCH_SCRIPT = String.raw`
|
|
$ErrorActionPreference = 'Stop'
|
|
$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::UTF8
|
|
$config = Get-Content -LiteralPath $env:NCURATOR_E2E_DESKTOP_LAUNCH_CONFIG -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
$shell = New-Object -ComObject WScript.Shell
|
|
$original = $shell.CreateShortcut($config.entryPath)
|
|
if (-not [string]::Equals($original.TargetPath, $config.executablePath, [StringComparison]::OrdinalIgnoreCase)) {
|
|
throw 'Installed shortcut target does not match the E2E executable'
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($original.WorkingDirectory)) {
|
|
throw 'Installed shortcut has no explicit working directory; a debug copy cannot preserve its launch context'
|
|
}
|
|
$copyPath = Join-Path $config.directory 'instrumented.lnk'
|
|
Copy-Item -LiteralPath $config.entryPath -Destination $copyPath
|
|
$copy = $shell.CreateShortcut($copyPath)
|
|
# Keep the installer target and WorkingDirectory. Only add observer debug args
|
|
# to a private copy; the user's installed shortcut remains untouched.
|
|
$arguments = @($config.arguments | ForEach-Object { '"' + $_.Replace('"', '\"') + '"' }) -join ' '
|
|
$copy.Arguments = ($original.Arguments + ' ' + $arguments).Trim()
|
|
$copy.Save()
|
|
$process = Start-Process -FilePath $copyPath -PassThru
|
|
if ($null -eq $process) { throw 'Shell did not return the launched application process' }
|
|
# Open the process handle before exit so its exit code remains readable.
|
|
$null = $process.Handle
|
|
[Console]::WriteLine((@{pid=$process.Id; workingDirectory=$original.WorkingDirectory} | ConvertTo-Json -Compress))
|
|
$process.WaitForExit()
|
|
[Console]::WriteLine((@{event='exit'; exitCode=$process.ExitCode} | ConvertTo-Json -Compress))
|
|
`
|
|
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
const readEndpoint = async (port, route) => {
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}${route}`, {
|
|
signal: AbortSignal.timeout(500)
|
|
})
|
|
if (!response.ok) return null
|
|
const body = await response.json()
|
|
return Array.isArray(body) ? body[0]?.webSocketDebuggerUrl : body.webSocketDebuggerUrl
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
const inspectIdentity = (endpoint) => {
|
|
// Node's inspector can keep a WebSocket close handshake pending. A short-lived
|
|
// observer guarantees this initial identity connection cannot hold the app open.
|
|
const script = `
|
|
const socket = new WebSocket(process.argv[1]);
|
|
setTimeout(() => process.exit(1), 3000);
|
|
socket.addEventListener('error', () => process.exit(1));
|
|
socket.addEventListener('open', () => socket.send(JSON.stringify({
|
|
id: 1, method: 'Runtime.evaluate', params: {
|
|
expression: 'JSON.stringify({pid:process.pid,executablePath:process.execPath,userDataPath:process.env.NCURATOR_USER_DATA_PATH})',
|
|
returnByValue: true
|
|
}
|
|
})));
|
|
socket.addEventListener('message', message => {
|
|
const response = JSON.parse(String(message.data));
|
|
if (response.id !== 1) return;
|
|
const value = response.result?.result?.value;
|
|
if (!value || response.error) process.exit(1);
|
|
process.stdout.write(value, () => process.exit(0));
|
|
});
|
|
`
|
|
const result = spawnSync(process.execPath, ['--input-type=module', '-e', script, endpoint], {
|
|
encoding: 'utf8',
|
|
timeout: 5000,
|
|
windowsHide: true
|
|
})
|
|
if (result.error || result.status !== 0) throw new Error('Inspector identity unavailable')
|
|
return JSON.parse(result.stdout)
|
|
}
|
|
|
|
const isPortClosed = (port) =>
|
|
new Promise((resolve) => {
|
|
const socket = createConnection({ host: '127.0.0.1', port })
|
|
socket.setTimeout(1000)
|
|
socket.once('connect', () => {
|
|
socket.destroy()
|
|
resolve(false)
|
|
})
|
|
socket.once('timeout', () => {
|
|
socket.destroy()
|
|
resolve(false)
|
|
})
|
|
socket.once('error', (error) => resolve(error.code === 'ECONNREFUSED'))
|
|
})
|
|
|
|
const runDesktopLaunch = async () => {
|
|
const configPath = process.env[CONFIG_ENV]
|
|
if (!configPath) throw new Error('Missing desktop launch configuration')
|
|
const config = JSON.parse(await readFile(configPath, 'utf8'))
|
|
const [nodePort, browserPort] = await Promise.all([reservePort(), reservePort()])
|
|
config.arguments = process.argv.slice(2).map((argument) => {
|
|
if (argument === '--inspect=0') return `--inspect=127.0.0.1:${nodePort}`
|
|
if (argument === '--remote-debugging-port=0') return `--remote-debugging-port=${browserPort}`
|
|
return argument
|
|
})
|
|
await writeFile(configPath, JSON.stringify(config), { mode: 0o600 })
|
|
let state = {
|
|
method: process.platform === 'darwin' ? 'launch-services' : 'installed-shortcut',
|
|
entryPath: config.entryPath,
|
|
executablePath: config.executablePath,
|
|
pid: null,
|
|
exitCode: null,
|
|
exited: false
|
|
}
|
|
const saveState = () => writeFileSync(config.statePath, JSON.stringify(state), { mode: 0o600 })
|
|
saveState()
|
|
let launcher
|
|
if (process.platform === 'darwin') {
|
|
const scriptPath = path.join(config.directory, 'launch.js')
|
|
await writeFile(scriptPath, MAC_LAUNCH_SCRIPT)
|
|
launcher = spawn('/usr/bin/osascript', ['-l', 'JavaScript', scriptPath, configPath], {
|
|
env: config.environment,
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
})
|
|
} else if (process.platform === 'win32') {
|
|
launcher = spawn('powershell.exe', powershellCommand(WINDOWS_LAUNCH_SCRIPT), {
|
|
env: { ...config.environment, [CONFIG_ENV]: configPath },
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsHide: true
|
|
})
|
|
} else {
|
|
throw new Error('Desktop package E2E requires macOS or Windows')
|
|
}
|
|
let launchError = ''
|
|
let launcherStderr = ''
|
|
let stdout = ''
|
|
launcher.stderr.on('data', (data) => {
|
|
launcherStderr += String(data)
|
|
process.stderr.write(data)
|
|
})
|
|
launcher.stdout.on('data', (data) => {
|
|
stdout += String(data)
|
|
const lines = stdout.split(/\r?\n/u)
|
|
stdout = lines.pop() || ''
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue
|
|
try {
|
|
const event = JSON.parse(line)
|
|
if (event.pid) state = { ...state, ...event }
|
|
if (event.event === 'exit') state.exitCode = event.exitCode
|
|
saveState()
|
|
} catch {
|
|
process.stderr.write(`${line}\n`)
|
|
}
|
|
}
|
|
})
|
|
launcher.on('error', (error) => {
|
|
launchError = error.message
|
|
})
|
|
launcher.on('close', (code) => {
|
|
if (stdout.trim()) {
|
|
try {
|
|
const event = JSON.parse(stdout)
|
|
if (event.pid) state = { ...state, ...event }
|
|
saveState()
|
|
} catch {
|
|
process.stderr.write(`${stdout}\n`)
|
|
}
|
|
}
|
|
if (code !== 0) {
|
|
launchError ||= launcherStderr || `Desktop launcher exited with code ${String(code)}`
|
|
process.exitCode = 1
|
|
}
|
|
})
|
|
const cleanup = () => {
|
|
terminateDesktopProcess(state.pid)
|
|
launcher.kill()
|
|
}
|
|
process.once('SIGTERM', () => {
|
|
cleanup()
|
|
process.exit(1)
|
|
})
|
|
process.once('SIGINT', () => {
|
|
cleanup()
|
|
process.exit(1)
|
|
})
|
|
try {
|
|
const deadline = Date.now() + 90_000
|
|
let nodeReady = false
|
|
let browserReady = false
|
|
while (!state.pid || !nodeReady || !browserReady) {
|
|
if (launchError) throw new Error(launchError)
|
|
if (Date.now() > deadline) throw new Error('Desktop application debug connection timed out')
|
|
if (!nodeReady) {
|
|
const endpoint = await readEndpoint(nodePort, '/json/list')
|
|
if (endpoint) {
|
|
const identity = inspectIdentity(endpoint)
|
|
if (
|
|
path.resolve(identity.executablePath).toLowerCase() !==
|
|
path.resolve(config.executablePath).toLowerCase() ||
|
|
identity.userDataPath !== config.environment.NCURATOR_USER_DATA_PATH ||
|
|
(state.pid && state.pid !== identity.pid)
|
|
) {
|
|
throw new Error('Desktop launcher connected to an unexpected application process')
|
|
}
|
|
state.pid = identity.pid
|
|
saveState()
|
|
process.stderr.write(`Debugger listening on ${endpoint}\n`)
|
|
nodeReady = true
|
|
}
|
|
}
|
|
if (!browserReady) {
|
|
const endpoint = await readEndpoint(browserPort, '/json/version')
|
|
if (endpoint) {
|
|
process.stderr.write(`DevTools listening on ${endpoint}\n`)
|
|
browserReady = true
|
|
}
|
|
}
|
|
await sleep(100)
|
|
}
|
|
// Playwright keeps its normal Electron API while connecting to the actual
|
|
// desktop-launched app. This process only relays connection/lifecycle data.
|
|
let disconnected = false
|
|
while (true) {
|
|
try {
|
|
process.kill(state.pid, 0)
|
|
} catch {
|
|
break
|
|
}
|
|
// Launch Services owns the process. A terminated app may remain as a
|
|
// zombie briefly; kill(pid, 0) alone still reports that PID as present.
|
|
if (disconnected && process.platform === 'darwin') {
|
|
const status = spawnSync('/bin/ps', ['-p', String(state.pid), '-o', 'stat='], {
|
|
encoding: 'utf8'
|
|
})
|
|
if (status.status !== 0 || status.stdout.trim().startsWith('Z')) break
|
|
}
|
|
if (
|
|
!disconnected &&
|
|
(existsSync(config.disconnectPath) || (await isPortClosed(browserPort)))
|
|
) {
|
|
process.stderr.write('Waiting for the debugger to disconnect...\n')
|
|
disconnected = true
|
|
}
|
|
await sleep(250)
|
|
}
|
|
state.exited = true
|
|
saveState()
|
|
if (state.exitCode !== null && state.exitCode !== 0) process.exitCode = 1
|
|
} catch (error) {
|
|
cleanup()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
runDesktopLaunch().catch((error) => {
|
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
|
|
process.exitCode = 1
|
|
})
|
|
}
|