614 lines
22 KiB
JavaScript
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);
|
|
}
|