Commit 80e3ebdb authored by AI-甘富林's avatar AI-甘富林

test(desktop): add single-instance smoke script

parent dbd31e98
......@@ -30,5 +30,6 @@
- `project-bundle-churn-smoke.ps1` compiles the targeted `project-bundle-churn-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies multi-project churn with stable survivors, same-project replacement, project removal, project addition, active-project fallback, and session survival inside unaffected projects; `pnpm smoke:bundle-churn`
- `workspace-startup-smoke.ps1` compiles the targeted `workspace-startup-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies packaged startup error classification plus local OpenClaw isolation policy; `pnpm smoke:workspace-startup`
- `startup-binding-smoke.ps1` launches Electron in bundled-runtime mode without an employee key, asserts the startup overlay appears first, and verifies the bind-entry only appears after shell prewarm completes; `powershell -ExecutionPolicy Bypass -File build/scripts/startup-binding-smoke.ps1`
- `desktop-single-instance-smoke.ps1` launches Electron in bundled-runtime mode, starts a second copy against the same `userData`, and verifies the first instance is re-focused without creating a second runtime start or gateway-conflict log sequence; `pnpm smoke:desktop-single-instance`
- `chat-gateway-recovery-smoke.ps1` compiles the targeted `chat-gateway-recovery-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies send-time gateway readiness enforcement plus single-shot reconnect/retry for `Gateway websocket is not open.`; `pnpm smoke:chat-gateway-recovery`
- `project-isolation-smoke.ps1` runs the main project-isolation regression gate end to end, including workspace-entry, default-chat, cloud-bundle Electron lifecycle coverage, project-context refresh, empty-project inventory, bundle reconcile, bundle freshness, bundle replacement, and multi-project churn; `pnpm smoke:project-isolation`
param(
[int]$GatewayPort = 18889,
[string]$GatewayToken = 'qjc-bundled-runtime-token',
[string]$SmokeOutput,
[string]$UserDataPath,
[string]$LogsPath,
[int]$TimeoutSeconds = 180,
[switch]$SkipMaterializeRuntime
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$electron = Join-Path $repoRoot 'node_modules\.pnpm\electron@34.5.8\node_modules\electron\dist\electron.exe'
$desktopApp = Join-Path $repoRoot 'apps\desktop'
$rendererUrl = Join-Path $repoRoot 'apps\desktop\dist\renderer\index.html'
if (-not (Test-Path $electron)) {
throw "Electron executable not found at $electron"
}
if (-not $SmokeOutput) {
$SmokeOutput = Join-Path $repoRoot '.tmp\desktop-single-instance-smoke\result.json'
}
if (-not $UserDataPath) {
$UserDataPath = Join-Path $repoRoot '.tmp\desktop-single-instance-smoke\user-data'
}
if (-not $LogsPath) {
$LogsPath = Join-Path $repoRoot '.tmp\desktop-single-instance-smoke\logs'
}
$SmokeOutput = [System.IO.Path]::GetFullPath($SmokeOutput)
$UserDataPath = [System.IO.Path]::GetFullPath($UserDataPath)
$LogsPath = [System.IO.Path]::GetFullPath($LogsPath)
$readyPath = $SmokeOutput + '.ready.json'
$eventPath = $SmokeOutput + '.second-instance.json'
$tracePath = $SmokeOutput + '.trace.log'
function Write-Utf8File {
param([string]$FilePath, [string]$Content)
$encoding = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($FilePath, $Content, $encoding)
}
function Write-SmokeFailureOutput {
param(
[string]$Message,
[string]$Stage
)
if (Test-Path $SmokeOutput) {
return
}
$payload = [ordered]@{
ok = $false
stage = $Stage
error = $Message
finishedAt = (Get-Date).ToUniversalTime().ToString('o')
smokeOutput = $SmokeOutput
readyPath = if (Test-Path $readyPath) { $readyPath } else { $null }
eventPath = if (Test-Path $eventPath) { $eventPath } else { $null }
tracePath = if (Test-Path $tracePath) { $tracePath } else { $null }
userDataPath = $UserDataPath
logsPath = $LogsPath
}
Write-Utf8File $SmokeOutput ($payload | ConvertTo-Json -Depth 6)
}
foreach ($pathValue in @($SmokeOutput, $UserDataPath, $LogsPath)) {
$parent = Split-Path $pathValue -Parent
if ($parent) {
New-Item -ItemType Directory -Force -Path $parent | Out-Null
}
}
if (-not $SkipMaterializeRuntime) {
Write-Host "Materializing bundled runtime payload on port $GatewayPort"
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\materialize-runtime-payload.ps1') -GatewayPort $GatewayPort -GatewayToken $GatewayToken
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
}
foreach ($cleanupPath in @($SmokeOutput, $readyPath, $eventPath, $tracePath)) {
if (Test-Path $cleanupPath) {
Remove-Item $cleanupPath -Force -ErrorAction SilentlyContinue
}
}
if (Test-Path $UserDataPath) {
Remove-Item $UserDataPath -Recurse -Force -ErrorAction SilentlyContinue
}
if (Test-Path $LogsPath) {
Remove-Item $LogsPath -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Force -Path $UserDataPath, $LogsPath | Out-Null
Remove-Item Env:QJCLAW_SMOKE_CLOUD_API_BASE_URL -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_AUTH_TOKEN -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_PROMPT -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_SKILL_ID -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_VIEW_MODE -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_PROJECT_ID -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_EXPERT_ENTRY_ID -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_SEND_AFTER_EXPERT_ENTRY -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_SUGGESTION_ACTION -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_STARTUP_ONLY -ErrorAction SilentlyContinue
$env:QJCLAW_RENDERER_URL = $rendererUrl
$env:QJCLAW_SECRET_BACKEND = 'file-fallback'
$env:QJCLAW_USER_DATA_PATH = $UserDataPath
$env:QJCLAW_LOGS_PATH = $LogsPath
$env:QJCLAW_RUNTIME_MODE = 'bundled-runtime'
$env:QJCLAW_SMOKE_OUTPUT = $SmokeOutput
$env:QJCLAW_SMOKE_SINGLE_INSTANCE = '1'
$env:QJCLAW_SMOKE_SINGLE_INSTANCE_READY_PATH = $readyPath
$env:QJCLAW_SMOKE_SINGLE_INSTANCE_EVENT_PATH = $eventPath
$firstProcess = $null
$secondProcess = $null
try {
Write-Host "Launching first Electron instance with isolated userData at $UserDataPath"
$firstProcess = Start-Process -FilePath $electron -ArgumentList $desktopApp -PassThru
$readyDeadline = (Get-Date).AddSeconds([Math]::Min($TimeoutSeconds, 90))
while ((Get-Date) -lt $readyDeadline) {
if ((Test-Path $readyPath) -or (Test-Path $SmokeOutput)) {
break
}
Start-Sleep -Milliseconds 500
}
if (-not (Test-Path $readyPath)) {
if (-not (Test-Path $SmokeOutput)) {
Write-SmokeFailureOutput -Message "First Electron instance never reported single-instance readiness. Trace: $tracePath" -Stage 'desktop-single-instance-ready-timeout'
}
} else {
Write-Host 'Launching second Electron instance to trigger single-instance wake-up'
Remove-Item Env:QJCLAW_SMOKE_OUTPUT -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_SINGLE_INSTANCE -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_SINGLE_INSTANCE_READY_PATH -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_SINGLE_INSTANCE_EVENT_PATH -ErrorAction SilentlyContinue
$secondProcess = Start-Process -FilePath $electron -ArgumentList $desktopApp -PassThru
Wait-Process -Id $secondProcess.Id -Timeout 20 -ErrorAction SilentlyContinue
if (Get-Process -Id $secondProcess.Id -ErrorAction SilentlyContinue) {
Stop-Process -Id $secondProcess.Id -Force -ErrorAction SilentlyContinue
throw "Second Electron instance did not exit promptly after failing to acquire the single-instance lock."
}
}
$outputDeadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $outputDeadline) {
if (Test-Path $SmokeOutput) {
break
}
Start-Sleep -Milliseconds 500
}
if (-not (Test-Path $SmokeOutput)) {
Write-SmokeFailureOutput -Message "Single-instance smoke did not produce an output payload. Trace: $tracePath" -Stage 'desktop-single-instance-no-output'
}
if ($firstProcess -and (Get-Process -Id $firstProcess.Id -ErrorAction SilentlyContinue)) {
Wait-Process -Id $firstProcess.Id -Timeout 10 -ErrorAction SilentlyContinue
if (Get-Process -Id $firstProcess.Id -ErrorAction SilentlyContinue) {
Stop-Process -Id $firstProcess.Id -Force -ErrorAction SilentlyContinue
}
}
$validator = @'
const fs = require('fs');
const path = require('path');
const [smokeOutput, logsPath] = process.argv.slice(1);
const result = JSON.parse(fs.readFileSync(smokeOutput, 'utf8'));
if (!result.ok) {
throw new Error(result.error || 'Single-instance smoke failed.');
}
const singleInstance = result.singleInstance || {};
const lastEvent = singleInstance.lastSecondInstanceEventSnapshot || {};
const finalSnapshot = singleInstance.finalWindowSnapshot || {};
const launchStart = Number(singleInstance.initialLaunchCommandCount || 0);
const launchEnd = Number(singleInstance.finalLaunchCommandCount || 0);
if (launchStart < 1) {
throw new Error('Bundled runtime never launched during the first instance startup.');
}
if (launchEnd !== launchStart) {
throw new Error('Second launch triggered an extra bundled runtime start. before=' + launchStart + ' after=' + launchEnd);
}
if (Number(singleInstance.secondInstanceEventCount || 0) < 1) {
throw new Error('Primary instance did not record any second-instance event.');
}
if (Number(lastEvent.windowCount || 0) !== 1) {
throw new Error('Second-instance handler observed an unexpected window count: ' + Number(lastEvent.windowCount || 0));
}
if (Number(lastEvent.visibleWindowCount || 0) !== 1) {
throw new Error('Second-instance handler did not keep a single visible main window: ' + Number(lastEvent.visibleWindowCount || 0));
}
if (!lastEvent.mainWindow || !lastEvent.mainWindow.exists) {
throw new Error('Second-instance handler did not keep a live main window reference.');
}
if (Number(finalSnapshot.windowCount || 0) !== 1) {
throw new Error('Final window snapshot observed an unexpected window count: ' + Number(finalSnapshot.windowCount || 0));
}
const forbiddenPatterns = [
'gateway already running',
'Port 18889 is already in use',
'schtasks /End /TN'
];
const matchingLogHits = [];
const scan = (target) => {
const stat = fs.statSync(target);
if (stat.isDirectory()) {
for (const entry of fs.readdirSync(target)) {
scan(path.join(target, entry));
}
return;
}
const content = fs.readFileSync(target, 'utf8');
for (const pattern of forbiddenPatterns) {
if (content.includes(pattern)) {
matchingLogHits.push({ filePath: target, pattern });
}
}
};
if (fs.existsSync(logsPath)) {
scan(logsPath);
}
if (matchingLogHits.length > 0) {
throw new Error('Conflict markers were found in logs: ' + JSON.stringify(matchingLogHits));
}
const summary = {
ok: true,
smokeOutput,
logsPath,
initialLaunchCommandCount: launchStart,
finalLaunchCommandCount: launchEnd,
secondInstanceEventCount: Number(singleInstance.secondInstanceEventCount || 0),
windowCount: Number(lastEvent.windowCount || 0),
visibleWindowCount: Number(lastEvent.visibleWindowCount || 0),
focusedWindowCount: Number(lastEvent.focusedWindowCount || 0),
runtimeManagerLogPath: String(singleInstance.runtimeManagerLogPath || ''),
};
console.log(JSON.stringify(summary, null, 2));
'@
$summary = & node -e $validator $SmokeOutput $LogsPath
if ($LASTEXITCODE -ne 0) {
throw 'Desktop single-instance smoke validation failed.'
}
Write-Output $summary
}
finally {
if ($secondProcess -and (Get-Process -Id $secondProcess.Id -ErrorAction SilentlyContinue)) {
Stop-Process -Id $secondProcess.Id -Force -ErrorAction SilentlyContinue
}
if ($firstProcess -and (Get-Process -Id $firstProcess.Id -ErrorAction SilentlyContinue)) {
Stop-Process -Id $firstProcess.Id -Force -ErrorAction SilentlyContinue
}
Remove-Item Env:QJCLAW_RENDERER_URL -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SECRET_BACKEND -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_USER_DATA_PATH -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_LOGS_PATH -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_RUNTIME_MODE -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_OUTPUT -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_SINGLE_INSTANCE -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_SINGLE_INSTANCE_READY_PATH -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_SINGLE_INSTANCE_EVENT_PATH -ErrorAction SilentlyContinue
}
......@@ -27,6 +27,7 @@
"smoke:expert-bootstrap-prompt": "powershell -ExecutionPolicy Bypass -File build/scripts/expert-bootstrap-prompt-smoke.ps1",
"smoke:desktop-expert-bootstrap-ui": "powershell -ExecutionPolicy Bypass -File build/scripts/desktop-expert-bootstrap-ui-smoke.ps1",
"smoke:settings": "powershell -ExecutionPolicy Bypass -File build/scripts/settings-smoke.ps1",
"smoke:desktop-single-instance": "powershell -ExecutionPolicy Bypass -File build/scripts/desktop-single-instance-smoke.ps1",
"smoke:project-routing": "powershell -ExecutionPolicy Bypass -File build/scripts/project-routing-smoke.ps1",
"smoke:project-package-orchestrator": "powershell -ExecutionPolicy Bypass -File build/scripts/project-package-orchestrator-smoke.ps1",
"smoke:project-isolation": "powershell -ExecutionPolicy Bypass -File build/scripts/project-isolation-smoke.ps1",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment