Files

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
})
}