Files
iofficeai--aionui/scripts/smoke-installer-failure-messagebox.js
2026-07-13 13:20:22 +08:00

614 lines
22 KiB
JavaScript

#!/usr/bin/env node
const {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} = require('node:fs');
const { tmpdir } = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const repoRoot = path.resolve(__dirname, '..');
const INSTALLER_ERROR_SCENARIOS = [
{
id: 'uninstaller-copy-or-rebuild-failed',
defineName: 'AIONUI_E_UNINSTALLER_COPY_OR_REBUILD_FAILED',
code: 'E1001',
message: 'AionUi could not repair the installed uninstaller.',
action: 'Close AionUi, restart Windows if needed, then run this installer again.',
diagnostics:
'scenario=uninstaller-copy-or-rebuild-failed phase=uninstaller-repair result=copy-failed-retry-bundled-missing',
},
{
id: 'old-uninstall-failed',
defineName: 'AIONUI_E_OLD_UNINSTALL_FAILED',
code: 'E1002',
message: 'The previous AionUi uninstaller returned an error.',
action:
'Close any program using the install folder, then run this installer again. If no program is listed, restart Windows and run this installer again.',
diagnostics: 'scenario=old-uninstall-failed phase=old-uninstaller exitCode=2',
},
{
id: 'install-dir-remove-or-locked',
defineName: 'AIONUI_E_INSTALL_DIR_REMOVE_OR_LOCKED',
code: 'E1003',
message: 'AionUi could not remove or replace the previous installation directory.',
action: 'Close AionUi and any program using the install folder, then run this installer again.',
diagnostics: 'scenario=install-dir-remove-or-locked phase=atomic-failed failedPath=install-dir',
},
{
id: 'extract-failed',
defineName: 'AIONUI_E_EXTRACT_FAILED',
code: 'E1010',
message: 'AionUi could not extract the application files correctly.',
action: 'Download a fresh installer and run it again.',
diagnostics: 'scenario=extract-failed phase=extract method=zip missing=AionUi.exe',
},
{
id: 'disk-insufficient',
defineName: 'AIONUI_E_DISK_INSUFFICIENT',
code: 'E1020',
message: 'AionUi cannot continue because the target disk does not have enough free space.',
action: 'Free disk space on the target drive, then run this installer again.',
diagnostics: 'scenario=disk-insufficient phase=preflight requiredMb=1024 availableMb=0',
},
{
id: 'bundled-aioncore-incomplete',
defineName: 'AIONUI_E_BUNDLED_AIONCORE_INCOMPLETE',
code: 'E1030',
message: 'AionUi installed, but the bundled AionCore resources are incomplete.',
action: 'Download a fresh installer and run it again.',
diagnostics: 'scenario=bundled-aioncore-incomplete phase=verify-bundled-aioncore runtime=win32-x64 result=1',
},
{
id: 'core-app-files-incomplete',
defineName: 'AIONUI_E_CORE_APP_FILES_INCOMPLETE',
code: 'E1031',
message: 'AionUi installation is incomplete because a required application file is missing.',
action: 'Reinstall AionUi or download a newer installer.',
diagnostics: 'scenario=core-app-files-incomplete phase=verify-required-file missing=resources/app.asar',
},
{
id: 'arch-mismatch',
defineName: 'AIONUI_E_ARCH_MISMATCH',
code: 'E1040',
message: 'Installation package architecture mismatch.',
action: 'Download the AionUi installer that matches this Windows architecture, then run it again.',
diagnostics: 'scenario=arch-mismatch phase=arch-check target=x64 actual=arm64',
},
{
id: 'active-installer-conflict',
defineName: 'AIONUI_E_ACTIVE_INSTALLER_CONFLICT',
code: 'E1050',
message: 'Another AionUi installer appears to still be active.',
action: 'Close the other installer window or wait for it to finish, then run this installer again.',
diagnostics: 'scenario=active-installer-conflict phase=active-installer-marker state=active',
},
{
id: 'registry-state-invalid',
defineName: 'AIONUI_E_REGISTRY_STATE_INVALID',
code: 'E1060',
message: 'AionUi found an invalid previous-install registry state.',
action: 'Uninstall the old AionUi from Windows Settings, then run this installer again.',
diagnostics: 'scenario=registry-state-invalid phase=registry-heal installLocation=invalid uninstallString=missing',
},
{
id: 'active-marker-write-failed',
defineName: 'AIONUI_E_ACTIVE_MARKER_WRITE_FAILED',
code: 'E1070',
message: 'AionUi could not write the active-installer marker.',
action: 'Restart Windows, then run this installer again.',
diagnostics: 'scenario=active-marker-write-failed phase=active-installer-marker-write result=failed',
},
{
id: 'invalid-install-path',
defineName: 'AIONUI_E_INVALID_INSTALL_PATH',
code: 'E1090',
message: 'The selected install path is invalid.',
action: 'Choose a local install path that is writable, then run this installer again.',
diagnostics: 'scenario=invalid-install-path phase=path-validation installPath=invalid',
},
];
function nsisQuote(value) {
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '$\\"').replace(/\$/g, '$$');
}
function findMakensis() {
if (process.env.MAKENSIS && existsSync(process.env.MAKENSIS)) {
return process.env.MAKENSIS;
}
const localAppData = process.env.LOCALAPPDATA;
const cacheRoot = localAppData ? path.join(localAppData, 'electron-builder', 'Cache') : '';
const candidates = [];
function walk(dir, depth = 0) {
if (!dir || depth > 5 || !existsSync(dir)) {
return;
}
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full, depth + 1);
} else if (entry.isFile() && entry.name.toLowerCase() === 'makensis.exe') {
candidates.push(full);
}
}
}
walk(cacheRoot);
candidates.sort((a, b) => b.localeCompare(a));
if (candidates[0]) {
return candidates[0];
}
const fromPath = spawnSync('where.exe', ['makensis.exe'], { encoding: 'utf8' });
if (fromPath.status === 0) {
const first = fromPath.stdout.split(/\r?\n/).find(Boolean);
if (first && existsSync(first)) {
return first;
}
}
throw new Error('makensis.exe not found. Run a Windows build once or set MAKENSIS=C:\\path\\to\\makensis.exe');
}
function copyHarnessProject(projectRoot) {
const windowsDir = path.join(projectRoot, 'resources', 'windows');
const supportDir = path.join(windowsDir, 'support');
mkdirSync(supportDir, { recursive: true });
for (const file of ['installer-observability.nsh', 'installer-errors-sentry.nsh', 'installer-messages.nsh']) {
copyFileSync(path.join(repoRoot, 'resources', 'windows', file), path.join(windowsDir, file));
}
copyFileSync(
path.join(repoRoot, 'resources', 'windows', 'support', 'report-installer-failure.ps1'),
path.join(supportDir, 'report-installer-failure.ps1')
);
writeFileSync(path.join(supportDir, '_sentry-dsn.generated.nsh'), '!define AIONUI_SENTRY_DSN ""\n', 'utf8');
}
function getArg(name, fallback) {
const prefix = `${name}=`;
const hit = process.argv.find((arg) => arg.startsWith(prefix));
return hit ? hit.slice(prefix.length) : fallback;
}
function readInstallerErrorDefinitions() {
const source = readFileSync(path.join(repoRoot, 'resources', 'windows', 'installer-errors-sentry.nsh'), 'utf8');
const definitions = Array.from(source.matchAll(/!define\s+(AIONUI_E_[A-Z0-9_]+)\s+"(E\d{4})"/g), (match) => ({
defineName: match[1],
code: match[2],
}));
if (definitions.length === 0) {
throw new Error('No AIONUI_E_* installer error codes found.');
}
return definitions;
}
function getInstallerErrorScenarioMatrix() {
const definitions = readInstallerErrorDefinitions();
const codes = definitions.map((definition) => definition.code);
const defineNames = definitions.map((definition) => definition.defineName);
const scenarioCodes = INSTALLER_ERROR_SCENARIOS.map((scenario) => scenario.code);
const scenarioDefineNames = INSTALLER_ERROR_SCENARIOS.map((scenario) => scenario.defineName);
const scenarioIds = INSTALLER_ERROR_SCENARIOS.map((scenario) => scenario.id);
if (definitions.length !== 12) {
throw new Error(`Expected 12 installer error code definitions, found ${definitions.length}: ${codes.join(', ')}`);
}
if (new Set(codes).size !== definitions.length) {
throw new Error(`Duplicate installer error codes in NSIS definitions: ${codes.join(', ')}`);
}
if (new Set(defineNames).size !== definitions.length) {
throw new Error(`Duplicate installer error define names in NSIS definitions: ${defineNames.join(', ')}`);
}
if (INSTALLER_ERROR_SCENARIOS.length !== definitions.length) {
throw new Error(
`Expected ${definitions.length} installer error scenarios, found ${INSTALLER_ERROR_SCENARIOS.length}`
);
}
if (new Set(scenarioIds).size !== INSTALLER_ERROR_SCENARIOS.length) {
throw new Error(`Duplicate installer error scenario ids: ${scenarioIds.join(', ')}`);
}
if (new Set(scenarioCodes).size !== INSTALLER_ERROR_SCENARIOS.length) {
throw new Error(`Duplicate installer error scenario codes: ${scenarioCodes.join(', ')}`);
}
for (let index = 0; index < definitions.length; index += 1) {
const definition = definitions[index];
const scenario = INSTALLER_ERROR_SCENARIOS[index];
if (scenario.defineName !== definition.defineName || scenario.code !== definition.code) {
throw new Error(
`Installer error scenario ${index + 1} does not match NSIS definition: expected ${definition.defineName}=${definition.code}, got ${scenario.defineName}=${scenario.code}`
);
}
}
return { definitions, scenarios: INSTALLER_ERROR_SCENARIOS };
}
function findInstallerErrorScenario(code) {
const { scenarios } = getInstallerErrorScenarioMatrix();
const scenario = scenarios.find((entry) => entry.code === code);
if (!scenario) {
throw new Error(`Unknown installer error code: ${code}`);
}
return scenario;
}
function writeAutoDeclineScript(scriptPath) {
writeFileSync(
scriptPath,
`
param(
[string]$ExePath,
[string]$Code,
[string]$ScenarioId,
[string]$LogPath,
[ValidateSet('yes', 'no')]
[string]$Answer
)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName UIAutomationClient
Add-Type -AssemblyName UIAutomationTypes
function Get-WindowText([System.Windows.Automation.AutomationElement]$Window) {
$texts = New-Object System.Collections.Generic.List[string]
$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
$queue = New-Object System.Collections.Queue
$queue.Enqueue($Window)
while ($queue.Count -gt 0) {
$item = [System.Windows.Automation.AutomationElement]$queue.Dequeue()
$name = $item.Current.Name
if ($name -and -not $texts.Contains($name)) { $texts.Add($name) }
$child = $walker.GetFirstChild($item)
while ($child) {
$queue.Enqueue($child)
$child = $walker.GetNextSibling($child)
}
}
return ($texts -join "\`n")
}
function Try-ClickWindowButton([System.Windows.Automation.AutomationElement]$Window, [string[]]$ButtonNames) {
$buttonCond = New-Object System.Windows.Automation.PropertyCondition(
[System.Windows.Automation.AutomationElement]::ControlTypeProperty,
[System.Windows.Automation.ControlType]::Button
)
$buttons = $Window.FindAll([System.Windows.Automation.TreeScope]::Descendants, $buttonCond)
foreach ($button in $buttons) {
foreach ($name in $ButtonNames) {
if ($button.Current.Name -eq $name -or $button.Current.Name -like "*$name*") {
if (-not $button.Current.IsEnabled) { continue }
$pattern = $button.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern)
$pattern.Invoke()
return $true
}
}
}
return $false
}
function Find-FailureWindow([string]$Code, [int]$TimeoutSec = 90) {
$deadline = (Get-Date).AddSeconds($TimeoutSec)
$root = [System.Windows.Automation.AutomationElement]::RootElement
$windowCond = New-Object System.Windows.Automation.PropertyCondition(
[System.Windows.Automation.AutomationElement]::ControlTypeProperty,
[System.Windows.Automation.ControlType]::Window
)
do {
$windows = $root.FindAll([System.Windows.Automation.TreeScope]::Children, $windowCond)
foreach ($window in $windows) {
$text = Get-WindowText $window
if ($text -like "*AionUi installation failed ($Code)*" -or
($text -like "*($Code)*" -and $text -like '*Send this installer failure report*')) {
return [ordered]@{ window = $window; text = $text; title = $window.Current.Name }
}
}
Start-Sleep -Milliseconds 300
} while ((Get-Date) -lt $deadline)
throw "Failure window not found for $Code"
}
$proc = Start-Process -FilePath $ExePath -PassThru
try {
$failure = Find-FailureWindow $Code
foreach ($required in @(
"AionUi installation failed ($Code)",
"scenario=$ScenarioId",
'Suggested action:',
'Diagnostics:',
'Installer log:',
'Send this installer failure report'
)) {
if ($failure.text -notlike "*$required*") {
throw "Failure dialog for $Code is missing: $required"
}
}
$logFileName = Split-Path -Leaf $LogPath
if ($failure.text -notlike "*$LogPath*" -and $failure.text -notlike "*$logFileName*") {
throw "Failure dialog for $Code does not include the installer log file name."
}
if ($failure.text -like '*Blocking diagnostics:*') {
throw "Failure dialog for $Code still uses the old Blocking diagnostics label."
}
if ($Answer -eq 'yes') {
$yesZh = [string][char]26159
$buttons = @('Yes', $yesZh, "$yesZh(Y)", "$yesZh(&Y)")
} else {
$noZh = [string][char]21542
$buttons = @('No', $noZh, "$noZh(N)", "$noZh(&N)")
}
if (-not (Try-ClickWindowButton $failure.window $buttons)) {
throw "$Answer button not found for $Code failure dialog."
}
if (-not $proc.WaitForExit(60000)) {
throw "Harness did not exit after declining report for $Code."
}
if ($proc.ExitCode -ne 2) {
throw "Harness exited with $($proc.ExitCode) for $Code; expected 2."
}
[pscustomobject]@{ code = $Code; answer = $Answer; exitCode = $proc.ExitCode; title = $failure.title; logPath = $LogPath } |
ConvertTo-Json -Compress
} finally {
if (-not $proc.HasExited) {
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
}
}
`,
'utf8'
);
}
function createHarnessNsi({ exePath, logPath, projectRoot, scenario }) {
const detail = `${scenario.diagnostics} smoke=messagebox`;
return `
Unicode true
Name "AionUi Failure MessageBox Smoke"
OutFile "${nsisQuote(exePath)}"
RequestExecutionLevel user
SilentInstall normal
!define PROJECT_DIR "${nsisQuote(projectRoot)}"
!define VERSION "0.0.0-smoke"
!define AIONUI_TARGET_ARCH "x64"
!define AIONUI_RUNTIME_KEY "win32-x64"
!include LogicLib.nsh
!include nsDialogs.nsh
!include "${nsisQuote(path.join(projectRoot, 'resources', 'windows', 'installer-observability.nsh'))}"
!macro AIONUI_CLEAR_ACTIVE_INSTALLER_MARKER
!macroend
!include "${nsisQuote(path.join(projectRoot, 'resources', 'windows', 'installer-errors-sentry.nsh'))}"
Section
StrCpy $INSTDIR "$TEMP\\AionUi-messagebox-smoke"
StrCpy $AionUiSessionId "smokembox-${nsisQuote(scenario.code)}"
StrCpy $AionUiIsUpdated "1"
StrCpy $AionUiSessionLogPath "${nsisQuote(logPath)}"
BringToFront
!insertmacro AIONUI_FAIL_UX \
"${nsisQuote(scenario.code)}" \
"${nsisQuote(detail)}" \
"${nsisQuote(scenario.message)}" \
"${nsisQuote(scenario.message)}" \
"${nsisQuote(scenario.action)}" \
"${nsisQuote(scenario.action)}" \
"${nsisQuote(detail)}" \
"${nsisQuote(detail)}"
SectionEnd
`;
}
function verifyFailureLog(logPath, scenario, expectedReportReason) {
const { code, id } = scenario;
if (!existsSync(logPath)) {
throw new Error(`installer log was not written for ${code}: ${logPath}`);
}
const events = readFileSync(logPath, 'utf8')
.split(/\r?\n/)
.filter((line) => line.trim())
.map((line) => JSON.parse(line.replace(/^\uFEFF/, '')));
const hasSessionFailure = events.some(
(event) =>
event.event === 'session-end' &&
typeof event.message === 'string' &&
event.message.includes(`code=${code}`) &&
event.message.includes(`scenario=${id}`)
);
const hasReportSkipped = events.some(
(event) =>
event.event === 'report-skipped' &&
((typeof event.message === 'string' &&
event.message.includes(`code=${code}`) &&
event.message.includes(`reason=${expectedReportReason}`)) ||
(event.code === code && event.reason === expectedReportReason))
);
if (!hasSessionFailure) {
throw new Error(`session-end failure event missing code or scenario id for ${code} (${id}): ${logPath}`);
}
if (!hasReportSkipped) {
throw new Error(`report-skipped event missing after declining report for ${code}: ${logPath}`);
}
}
function runHarness({ autoDecline, compileOnly, makensis, scenario }) {
const { code } = scenario;
const root = mkdtempSync(path.join(tmpdir(), `aionui-failure-messagebox-${code}-`));
const projectRoot = path.join(root, 'project');
const nsiPath = path.join(root, 'aionui-failure-messagebox-smoke.nsi');
const exePath = path.join(root, 'aionui-failure-messagebox-smoke.exe');
const logPath = path.join(
process.env.TEMP || tmpdir(),
`aionui-installer-messagebox-smoke-${code}-${new Date().toISOString().replace(/[-:]/g, '').replace(/\..+$/, '').replace('T', '-')}-log.jsonl`
);
const automationPath = path.join(root, 'auto-decline.ps1');
const reportStatusPath = path.join(process.env.TEMP || tmpdir(), 'aionui-installer-report.json');
copyHarnessProject(projectRoot);
writeAutoDeclineScript(automationPath);
writeFileSync(nsiPath, createHarnessNsi({ exePath, logPath, projectRoot, scenario }), 'utf8');
try {
console.log(`[failure-messagebox] ${code}: compiling harness...`);
const compile = spawnSync(makensis, [nsiPath], { encoding: 'utf8' });
if (compile.status !== 0) {
process.stdout.write(compile.stdout || '');
process.stderr.write(compile.stderr || '');
throw new Error(`makensis failed with exit ${compile.status}`);
}
if (compileOnly) {
console.log(`[failure-messagebox] ${code}: compile-only ok: ${exePath}`);
return { code, exePath, logPath, mode: 'compile-only' };
}
if (autoDecline) {
const answer = autoDecline === 'consent' ? 'yes' : 'no';
const expectedReportReason = autoDecline === 'consent' ? 'empty-dsn' : 'user-declined';
rmSync(reportStatusPath, { force: true });
console.log(
`[failure-messagebox] ${code}: launching harness and auto-${answer === 'yes' ? 'consenting to' : 'declining'} report...`
);
const run = spawnSync(
'powershell.exe',
[
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-File',
automationPath,
exePath,
code,
scenario.id,
logPath,
answer,
],
{ encoding: 'utf8' }
);
if (run.status !== 0) {
process.stdout.write(run.stdout || '');
process.stderr.write(run.stderr || '');
throw new Error(`auto-decline harness failed for ${code} with exit ${run.status}`);
}
verifyFailureLog(logPath, scenario, expectedReportReason);
if (autoDecline === 'consent') {
if (!existsSync(reportStatusPath)) {
throw new Error(`report status file missing for ${code}: ${reportStatusPath}`);
}
const status = JSON.parse(readFileSync(reportStatusPath, 'utf8'));
if (status.code !== code) {
throw new Error(`report status code mismatch for ${code}: ${status.code}`);
}
if (status.status !== 'skipped' || status.reason !== 'empty-dsn') {
throw new Error(`unexpected report status for ${code}: ${JSON.stringify(status)}`);
}
if (typeof status.copyText !== 'string' || !status.copyText.includes(`AionUi installer failure ${code}`)) {
throw new Error(`report copyText missing support payload for ${code}`);
}
}
console.log(`[failure-messagebox] ${code}: e2e ok: ${logPath}`);
return { code, exePath, logPath, mode: 'auto-decline' };
}
console.log('[failure-messagebox] launching harness. Click No to close without attempting report upload.');
const run = spawnSync(exePath, [], { stdio: 'inherit' });
if (run.status !== 2) {
throw new Error(`harness exited with ${run.status}; expected installer failure exit code 2`);
}
verifyFailureLog(logPath, scenario, 'user-declined');
return { code, exePath, logPath, mode: 'manual' };
} finally {
if (compileOnly || autoDecline || process.argv.includes('--cleanup')) {
rmSync(root, { recursive: true, force: true });
}
}
}
function main() {
if (process.argv.includes('--list-codes-json')) {
const { definitions, scenarios } = getInstallerErrorScenarioMatrix();
console.log(
JSON.stringify({
codes: definitions.map((definition) => definition.code),
scenarios: scenarios.map(({ id, defineName, code, message, action, diagnostics }) => ({
id,
defineName,
code,
message,
action,
diagnostics,
})),
})
);
return;
}
if (process.platform !== 'win32') {
throw new Error('This smoke test only runs on Windows.');
}
const allScenarios = process.argv.includes('--all-scenarios') || process.argv.includes('--all-codes');
const autoConsent = process.argv.includes('--auto-consent');
const autoDecline = process.argv.includes('--auto-decline');
if (autoConsent && autoDecline) {
throw new Error('Use only one of --auto-consent or --auto-decline.');
}
const compileOnly = process.argv.includes('--compile-only');
const { scenarios } = getInstallerErrorScenarioMatrix();
const selectedScenarios = allScenarios ? scenarios : [findInstallerErrorScenario(getArg('--code', 'E1003'))];
const makensis = findMakensis();
const results = [];
console.log(`[failure-messagebox] makensis: ${makensis}`);
for (const scenario of selectedScenarios) {
results.push(
runHarness({
autoDecline: autoConsent ? 'consent' : autoDecline ? 'decline' : '',
compileOnly,
makensis,
scenario,
})
);
}
if (allScenarios) {
console.log(
JSON.stringify(
{
coveredCodes: results.map((result) => result.code),
coveredScenarios: selectedScenarios.map((scenario) => scenario.id),
count: results.length,
mode: compileOnly ? 'compile-only' : autoConsent ? 'auto-consent' : autoDecline ? 'auto-decline' : 'manual',
},
null,
2
)
);
}
}
try {
main();
} catch (err) {
console.error(`[failure-messagebox] ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}