Files
maka/scripts/desktop-real-window-smoke.mjs
agent-radar 7be535059e
Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 19:05:14 +08:00

826 lines
30 KiB
JavaScript

#!/usr/bin/env node
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* PR-DESKTOP-SMOKE-0: real Electron window smoke runner.
*
* This is intentionally a human-in-the-loop gate. Automated fixtures
* cannot verify native macOS resize hit areas, titlebar drag, or keyboard
* traversal through a live Electron window. This script launches a clean,
* isolated Maka window and records the reviewer-confirmed result as JSON +
* Markdown under `apps/desktop/tests/real-window-smoke/`.
*/
import { execFile, spawn } from 'node:child_process';
import { terminateChildProcessTree } from '@maka/runtime/process-tree-terminator';
import { closeElectronApplication } from './electron-lifecycle.mjs';
import { buildFixtureEnv } from './fixture-env.mjs';
import { existsSync } from 'node:fs';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { createInterface } from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
import os from 'node:os';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(SCRIPT_DIR, '..');
const DESKTOP_DIR = join(REPO_ROOT, 'apps', 'desktop');
const REPORT_DIR = join(DESKTOP_DIR, 'tests', 'real-window-smoke');
const DEFAULT_SCENARIO = 'sidebar-search-modal-open';
const DEFAULT_WINDOW_WIDTH = 1280;
const DEFAULT_WINDOW_HEIGHT = 840;
const DEFAULT_DIAGNOSTIC_WAIT_MS = 3500;
export const PROGRAMMATIC_SMOKE_CHECKS = [
{
id: 'programmatic-window-visible',
prompt: 'BrowserWindow exists, is visible, and matches the requested smoke bounds.',
},
{
id: 'programmatic-window-flags',
prompt: 'BrowserWindow reports resizable + movable through Electron APIs.',
},
{
id: 'programmatic-renderer-mounted',
prompt: 'Renderer app frame is mounted and document.readyState is complete.',
},
{
id: 'programmatic-search-modal-open',
prompt: 'Search modal and backdrop mount in the deterministic smoke fixture.',
},
{
id: 'programmatic-focus-target',
prompt: 'Search modal receives focus on an interactive control after modal open.',
},
{
id: 'programmatic-no-error-boundary',
prompt: 'Renderer does not show the React ErrorBoundary surface.',
},
{
id: 'programmatic-dock-visible',
prompt:
'macOS: the app keeps its Dock tile, so a reviewer who switches away can switch back via Dock or Cmd+Tab.',
},
];
const STARTUP_SMOKE_CHECKS = [
{
id: 'startup-window-ready',
prompt: 'The real Maka BrowserWindow and renderer reach a usable ready state.',
},
];
export const REAL_WINDOW_SMOKE_CHECKS = [
{
id: 'launch-clean-window',
prompt:
'The app launches a real Electron window from a clean smoke user-data dir, with no ErrorBoundary/crash screen.',
},
{
id: 'resize-left-edge',
prompt: 'Dragging the left window edge resizes the window.',
},
{
id: 'resize-right-edge',
prompt: 'Dragging the right window edge resizes the window.',
},
{
id: 'resize-top-edge',
prompt: 'Dragging the top window edge resizes the window.',
},
{
id: 'resize-bottom-edge',
prompt: 'Dragging the bottom window edge resizes the window.',
},
{
id: 'resize-corners',
prompt: 'Dragging all four corners resizes diagonally.',
},
{
id: 'titlebar-drag',
prompt: 'Dragging an allowed titlebar/blank header region moves the window.',
},
{
id: 'controls-no-drag',
prompt: 'Sidebar rows, buttons, inputs, and modal controls do not drag the window.',
},
{
id: 'search-modal-cycle',
prompt: 'Search modal opens and closes with the close button, backdrop, and Escape.',
},
{
id: 'keyboard-path',
prompt:
'Tab/Shift+Tab stays inside the active modal, Enter activates focused controls, and Escape returns focus to the trigger.',
},
{
id: 'modal-resize-hit-area',
prompt: 'With Search modal open, window edges/corners still resize.',
},
{
id: 'renderer-health',
prompt:
'After closing the modal and switching sidebar modules, no ErrorBoundary or React hook error appears.',
},
];
function parseArgs(argv) {
const args = {
scenario: DEFAULT_SCENARIO,
width: DEFAULT_WINDOW_WIDTH,
height: DEFAULT_WINDOW_HEIGHT,
noLaunch: false,
cleanupStale: true,
assumeYes: false,
failNote: null,
unverifiedNote: null,
diagnosticWaitMs: DEFAULT_DIAGNOSTIC_WAIT_MS,
programmaticOnly: false,
startupOnly: false,
manual: false,
help: false,
};
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--scenario') args.scenario = argv[++i];
else if (arg === '--width') args.width = Number(argv[++i]);
else if (arg === '--height') args.height = Number(argv[++i]);
else if (arg === '--no-launch') args.noLaunch = true;
else if (arg === '--no-cleanup-stale') args.cleanupStale = false;
else if (arg === '--yes') args.assumeYes = true;
else if (arg === '--fail-note') args.failNote = argv[++i] ?? '';
else if (arg === '--unverified-note') args.unverifiedNote = argv[++i] ?? '';
else if (arg === '--diagnostic-wait-ms') args.diagnosticWaitMs = Number(argv[++i]);
else if (arg === '--programmatic-only') args.programmaticOnly = true;
else if (arg === '--startup-only') args.startupOnly = true;
else if (arg === '--manual') args.manual = true;
else if (arg === '--help' || arg === '-h') args.help = true;
else {
console.error(`[real-window-smoke] unknown arg: ${arg}`);
process.exit(2);
}
}
return args;
}
function printHelp() {
console.log(`Usage: desktop-real-window-smoke.mjs [--scenario name] [--width n] [--height n] [--no-launch] [--no-cleanup-stale] [--fail-note text] [--unverified-note text] [--diagnostic-wait-ms n] [--programmatic-only] [--startup-only] [--manual]
Launches a real Electron window with an isolated smoke workspace, then prompts
the reviewer to confirm native desktop behavior that screenshots cannot prove.
--manual launches the same window and stops there: no checklist, no report.
Use it to look at a fixture route in the running app — the window stays up
until you close it or press Ctrl-C.
Default scenario: ${DEFAULT_SCENARIO}
Report dir: ${relative(REPO_ROOT, REPORT_DIR)}
`);
}
function execFileText(file, args, options = {}) {
return new Promise((resolve) => {
execFile(file, args, options, (err, stdout, stderr) => {
resolve({
ok: !err,
stdout: stdout.toString(),
stderr: stderr.toString(),
});
});
});
}
function parsePsOutput(stdout) {
return stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const match = /^(\d+)\s+(\d+)\s+(.+)$/.exec(line);
if (!match) return null;
return { pid: Number(match[1]), ppid: Number(match[2]), command: match[3] };
})
.filter(Boolean);
}
function isStaleMakaElectronProcess(entry) {
if (!entry || entry.pid === process.pid) return false;
const command = entry.command;
if (!command.includes('Electron.app/Contents/MacOS/Electron')) return false;
if (!command.includes('--user-data-dir=')) return false;
return (
/--user-data-dir=(?:\/private)?\/tmp\/maka-(?:e2e-fixture|real-window-smoke|p0fix|uxp|v\d|copy|long|modal|narr|test-cap-debug)/.test(
command,
) || /--user-data-dir=(?:\/private)?\/var\/folders\/.+\/maka-real-window-smoke-/.test(command)
);
}
async function listElectronRootProcesses() {
const result = await execFileText('ps', ['-axo', 'pid,ppid,command']);
if (!result.ok) return [];
return parsePsOutput(result.stdout).filter((entry) =>
entry.command.includes('Electron.app/Contents/MacOS/Electron'),
);
}
async function cleanupStaleElectronProcesses(enabled) {
if (!enabled) return [];
const stale = (await listElectronRootProcesses()).filter(isStaleMakaElectronProcess);
if (stale.length === 0) return [];
console.log(
`[real-window-smoke] cleaning ${stale.length} stale Maka Electron process(es): ${stale.map((entry) => entry.pid).join(', ')}`,
);
await Promise.all(stale.map((entry) => execFileText('kill', [String(entry.pid)])));
await new Promise((resolve) => setTimeout(resolve, 750));
const survivors = (await listElectronRootProcesses()).filter((entry) =>
stale.some((staleEntry) => staleEntry.pid === entry.pid),
);
if (survivors.length > 0) {
console.log(
`[real-window-smoke] SIGKILL stale survivor(s): ${survivors.map((entry) => entry.pid).join(', ')}`,
);
await Promise.all(survivors.map((entry) => execFileText('kill', ['-9', String(entry.pid)])));
}
return stale.map(({ pid, ppid, command }) => ({ pid, ppid, command }));
}
async function ensureRepoRoot() {
const pkg = join(REPO_ROOT, 'package.json');
if (!existsSync(pkg)) {
console.error(`[real-window-smoke] cannot locate repo root (no package.json at ${pkg})`);
process.exit(2);
}
const root = JSON.parse(await readFile(pkg, 'utf8'));
if (!root.workspaces || !Array.isArray(root.workspaces)) {
console.error('[real-window-smoke] expected npm workspaces root; aborting.');
process.exit(2);
}
}
async function resolveElectronBin() {
const electronModule = join(REPO_ROOT, 'node_modules', 'electron');
if (!existsSync(electronModule)) {
console.error('[real-window-smoke] electron not installed; run `npm install` first.');
process.exit(2);
}
try {
const exportPath = (await import('electron')).default;
if (typeof exportPath === 'string') return exportPath;
} catch (err) {
console.error('[real-window-smoke] failed to resolve electron:', err);
process.exit(2);
}
console.error('[real-window-smoke] electron resolved to a non-string path.');
process.exit(2);
}
async function launchElectron(args, diagnostics) {
if (args.noLaunch) return null;
const electronBin = await resolveElectronBin();
const userDataDir = join(os.tmpdir(), `maka-real-window-smoke-${args.scenario}-${process.pid}`);
const homeDir = join(userDataDir, 'home');
await mkdir(homeDir, { recursive: true });
// The shared builder, not a hand-rolled `{ ...process.env }`: this gate was
// the last launcher still inheriting the environment wholesale, which meant
// a developer with `npm run dev` open smoked the dev server instead of the
// build this script just made (VITE_DEV_SERVER_URL), and the run touched
// the real $HOME. A fixture window also starts hidden for its whole
// lifecycle (`startHidden` in `main.ts`), which leaves this gate with
// nothing to look at — showWindow opts this run back into a visible window,
// and the dock rule follows it.
const env = buildFixtureEnv(userDataDir, homeDir, {
scenario: args.startupOnly ? undefined : args.scenario,
showWindow: true,
});
env.MAKA_E2E_FIXTURE_WIDTH = String(args.width);
env.MAKA_E2E_FIXTURE_HEIGHT = String(args.height);
env.MAKA_REAL_WINDOW_SMOKE = '1';
const launchArgs = ['.', `--user-data-dir=${userDataDir}`];
const child = spawn(electronBin, launchArgs, {
cwd: DESKTOP_DIR,
env,
stdio: ['ignore', 'pipe', 'pipe'],
// Group leader, like every runtime spawn: the shared tree terminator
// kills by process group (`kill(-pid)`), which only lands when the child
// owns its group. A visible Electron ignores SIGTERM, so this launcher
// depends on that escalation actually working.
detached: process.platform !== 'win32',
});
const launchCommand = `${electronBin} ${launchArgs.join(' ')}`;
diagnostics.launch = {
command: launchCommand,
cwd: DESKTOP_DIR,
electronPid: child.pid ?? null,
userDataDir,
};
let stdoutBuffer = '';
const diagnosticWaiters = [];
diagnostics.windowDiagnostics = [];
console.log(
`[real-window-smoke] launched Electron pid=${child.pid ?? 'unknown'} userDataDir=${userDataDir}`,
);
child.stdout.on('data', (chunk) => {
const text = chunk.toString();
process.stdout.write(chunk);
stdoutBuffer += text;
const lines = stdoutBuffer.split('\n');
stdoutBuffer = lines.pop() ?? '';
for (const line of lines) {
if (line.trim() === '[startup] app ready') {
diagnostics.startupReady = true;
for (const waiter of [...diagnosticWaiters]) {
if (waiter.stage === 'app-ready') waiter.finish(true);
}
}
const match = /^\[real-window-smoke\] diagnostic (.+)$/.exec(line.trim());
if (!match) continue;
try {
const diagnostic = JSON.parse(match[1]);
diagnostics.windowDiagnostics.push(diagnostic);
for (const waiter of [...diagnosticWaiters]) {
if (waiter.stage === diagnostic.stage) waiter.finish(true);
}
} catch {
diagnostics.windowDiagnostics.push({ parseError: line.trim() });
}
}
});
child.stderr.on('data', (chunk) => process.stderr.write(chunk));
child.on('exit', (code, signal) => {
if (code !== null) console.log(`[real-window-smoke] Electron exited with code ${code}`);
else if (signal) console.log(`[real-window-smoke] Electron exited via ${signal}`);
});
return {
child,
userDataDir,
command: launchCommand,
electronPid: child.pid ?? null,
waitForDiagnostics: (timeoutMs = 1500, stage = 'settled-1000ms') => {
if (stage === 'app-ready' && diagnostics.startupReady) return Promise.resolve(true);
if (diagnostics.windowDiagnostics.some((entry) => entry.stage === stage)) {
return Promise.resolve(true);
}
return new Promise((resolveDiagnostic) => {
const finish = (observed) => {
clearTimeout(timeout);
const index = diagnosticWaiters.indexOf(waiter);
if (index >= 0) diagnosticWaiters.splice(index, 1);
resolveDiagnostic(observed);
};
const timeout = setTimeout(() => finish(false), Math.max(0, timeoutMs));
const waiter = { stage, finish };
diagnosticWaiters.push(waiter);
});
},
};
}
async function promptChecks(args) {
if (args.unverifiedNote !== null) {
const note =
args.unverifiedNote.trim() ||
'real-window smoke could not verify the live window in this environment';
return REAL_WINDOW_SMOKE_CHECKS.map((check) => ({
...check,
status: 'UNVERIFIED',
ok: false,
note,
}));
}
if (args.failNote !== null) {
const note =
args.failNote.trim() || 'real-window smoke was explicitly marked as failed by the reviewer';
return REAL_WINDOW_SMOKE_CHECKS.map((check) => ({ ...check, status: 'FAIL', ok: false, note }));
}
const rl = createInterface({ input, output });
const results = [];
try {
for (const check of REAL_WINDOW_SMOKE_CHECKS) {
if (args.assumeYes) {
results.push({ ...check, status: 'PASS', ok: true, note: 'auto-confirmed by --yes' });
continue;
}
let answer = '';
while (!/^(y|yes|n|no)$/i.test(answer.trim())) {
answer = await rl.question(`[real-window-smoke] ${check.prompt} (y/n): `);
}
const ok = /^(y|yes)$/i.test(answer.trim());
let note = '';
if (!ok) {
note = await rl.question('[real-window-smoke] Failure note (what failed / where): ');
}
results.push({ ...check, status: ok ? 'PASS' : 'FAIL', ok, note });
}
} finally {
rl.close();
}
return results;
}
function latestWindowDiagnostic(diagnostics) {
const entries = diagnostics.windowDiagnostics ?? [];
for (let i = entries.length - 1; i >= 0; i -= 1) {
const entry = entries[i];
if (entry && entry.windowExists) return entry;
}
return null;
}
function buildProgrammaticResults(args, diagnostics) {
const diagnostic = latestWindowDiagnostic(diagnostics);
const renderer = diagnostic?.renderer ?? {};
const expectedWidth = Math.floor(args.width);
const expectedHeight = Math.floor(args.height);
const bounds = diagnostic?.bounds;
const checks = [
{
check: PROGRAMMATIC_SMOKE_CHECKS[0],
ok: Boolean(
diagnostic?.windowExists &&
diagnostic.isVisible === true &&
bounds?.width === expectedWidth &&
bounds?.height === expectedHeight,
),
note: diagnostic
? `visible=${diagnostic.isVisible} bounds=${JSON.stringify(bounds)} expected=${expectedWidth}x${expectedHeight}`
: 'no BrowserWindow diagnostic was captured',
},
{
check: PROGRAMMATIC_SMOKE_CHECKS[1],
ok: diagnostic?.isResizable === true && diagnostic?.isMovable === true,
note: diagnostic
? `isResizable=${diagnostic.isResizable} isMovable=${diagnostic.isMovable}`
: 'no BrowserWindow diagnostic was captured',
},
{
check: PROGRAMMATIC_SMOKE_CHECKS[2],
ok: renderer.readyState === 'complete' && renderer.appFramePresent === true,
note: `readyState=${renderer.readyState ?? 'unknown'} appFramePresent=${renderer.appFramePresent ?? 'unknown'}`,
},
{
check: PROGRAMMATIC_SMOKE_CHECKS[3],
ok: renderer.searchModalPresent === true && renderer.searchModalOpen === true,
note: `searchModalPresent=${renderer.searchModalPresent ?? 'unknown'} open=${renderer.searchModalOpen ?? 'unknown'}`,
},
{
check: PROGRAMMATIC_SMOKE_CHECKS[4],
// Focus must land on a control trapped inside the search modal. Check
// this structurally (activeElement is inside the stable search-modal
// contract root) rather
// than by a class on the focused element — the autocomplete input's
// implementation class is not part of this diagnostic contract.
ok:
renderer.activeElementInSearchModal === true &&
(renderer.activeElement?.tagName === 'INPUT' ||
renderer.activeElement?.tagName === 'BUTTON'),
note: `activeElementInSearchModal=${renderer.activeElementInSearchModal ?? 'unknown'} activeElement=${JSON.stringify(renderer.activeElement ?? null)}`,
},
{
check: PROGRAMMATIC_SMOKE_CHECKS[5],
ok: renderer.errorBoundaryPresent === false,
note: `errorBoundaryPresent=${renderer.errorBoundaryPresent ?? 'unknown'}`,
},
{
check: PROGRAMMATIC_SMOKE_CHECKS[6],
// Only macOS has a dock; elsewhere the diagnostic reports null and the
// check is vacuously satisfied.
ok: process.platform !== 'darwin' || diagnostic?.dockVisible === true,
note:
process.platform === 'darwin'
? `dockVisible=${diagnostic?.dockVisible ?? 'unknown'}`
: `skipped on ${process.platform}`,
},
];
return checks.map(({ check, ok, note }) => ({
...check,
layer: 'programmatic',
status: ok ? 'PASS' : 'FAIL',
ok,
note,
}));
}
function buildStartupResults(diagnostics) {
const diagnostic = latestWindowDiagnostic(diagnostics);
const renderer = diagnostic?.renderer ?? {};
const ok = Boolean(
diagnostics.startupReady === true &&
diagnostic?.stage === 'settled-1000ms' &&
diagnostic.windowExists === true &&
diagnostic.isVisible === true &&
renderer.readyState === 'complete' &&
renderer.appFramePresent === true &&
renderer.errorBoundaryPresent === false,
);
return [
{
check: STARTUP_SMOKE_CHECKS[0],
ok,
note: [
`appReady=${diagnostics.startupReady === true}`,
`stage=${diagnostic?.stage ?? 'missing'}`,
`windowExists=${diagnostic?.windowExists ?? false}`,
`visible=${diagnostic?.isVisible ?? false}`,
`readyState=${renderer.readyState ?? 'missing'}`,
`appFramePresent=${renderer.appFramePresent ?? false}`,
`errorBoundaryPresent=${renderer.errorBoundaryPresent ?? 'missing'}`,
].join(' '),
},
].map(({ check, ok, note }) => ({
...check,
layer: 'startup',
status: ok ? 'PASS' : 'FAIL',
ok,
note,
}));
}
function buildUnverifiedOsHitTestResults(args) {
const note =
args.unverifiedNote?.trim() ||
'OS hit-test requires Computer Use / real mouse access and was not verified in this run';
return REAL_WINDOW_SMOKE_CHECKS.map((check) => ({
...check,
layer: 'os-hit-test',
status: 'UNVERIFIED',
ok: false,
note,
}));
}
async function writeReport(args, launchInfo, results, diagnostics) {
await mkdir(REPORT_DIR, { recursive: true });
const now = new Date();
const stamp = now.toISOString().replace(/[:.]/g, '-');
const git = await readGitHead();
const status = deriveReportStatus(results);
const ok = status === 'PASS';
const report = {
ok,
status,
createdAt: now.toISOString(),
git,
scenario: args.scenario,
viewport: { width: args.width, height: args.height },
platform: `${os.platform()} ${os.arch()} ${os.release()}`,
userDataDir: launchInfo?.userDataDir ?? null,
diagnostics,
checks: results.map(({ id, layer, prompt, status: resultStatus, ok: resultOk, note }) => ({
id,
layer,
prompt,
status: resultStatus ?? (resultOk ? 'PASS' : 'FAIL'),
ok: resultOk,
note,
})),
};
const jsonPath = join(REPORT_DIR, `${stamp}.json`);
const mdPath = join(REPORT_DIR, `${stamp}.md`);
await writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`);
await writeFile(mdPath, renderMarkdown(report));
console.log(`[real-window-smoke] report: ${relative(REPO_ROOT, mdPath)}`);
return report;
}
function deriveReportStatus(results) {
if (results.every((result) => result.status === 'PASS' || result.ok === true)) {
return 'PASS';
}
if (results.every((result) => result.status === 'PASS' || result.status === 'UNVERIFIED')) {
return 'UNVERIFIED';
}
return 'FAIL';
}
async function readGitHead() {
try {
const { execFile } = await import('node:child_process');
return await new Promise((resolve) => {
execFile('git', ['rev-parse', '--short', 'HEAD'], { cwd: REPO_ROOT }, (err, stdout) => {
resolve(err ? 'unknown' : stdout.trim());
});
});
} catch {
return 'unknown';
}
}
function renderMarkdown(report) {
const lines = [
'# Maka Real Window Smoke Report',
'',
`- Result: ${report.status}`,
`- Created: ${report.createdAt}`,
`- Git: ${report.git}`,
`- Scenario: ${report.scenario}`,
`- Viewport: ${report.viewport.width}x${report.viewport.height}`,
`- Platform: ${report.platform}`,
report.userDataDir
? `- User data dir: \`${report.userDataDir}\``
: '- User data dir: not launched by script',
report.diagnostics?.launch?.electronPid
? `- Electron PID: ${report.diagnostics.launch.electronPid}`
: '- Electron PID: not launched by script',
report.diagnostics?.launch?.command
? `- Launch command: \`${escapeMd(report.diagnostics.launch.command)}\``
: '- Launch command: not launched by script',
`- Stale Electron processes cleaned: ${report.diagnostics?.staleElectronProcesses?.length ?? 0}`,
`- Window diagnostics captured: ${report.diagnostics?.windowDiagnostics?.length ?? 0}`,
'',
'| Check | Result | Note |',
'|---|---|---|',
];
for (const check of report.checks) {
lines.push(
`| ${check.id} | ${check.status ?? (check.ok ? 'PASS' : 'FAIL')} | ${escapeMd(check.note || check.prompt)} |`,
);
}
const programmaticChecks = report.checks.filter((check) => check.layer === 'programmatic');
const osHitTestChecks = report.checks.filter((check) => check.layer === 'os-hit-test');
if (programmaticChecks.length > 0 || osHitTestChecks.length > 0) {
lines.push('', '## Layer Summary', '');
if (programmaticChecks.length > 0) {
lines.push(`- Programmatic: ${summarizeLayer(programmaticChecks)}`);
}
if (osHitTestChecks.length > 0) {
lines.push(`- OS hit-test: ${summarizeLayer(osHitTestChecks)}`);
}
}
if ((report.diagnostics?.windowDiagnostics?.length ?? 0) > 0) {
lines.push('', '## Window Diagnostics', '');
for (const diagnostic of report.diagnostics.windowDiagnostics) {
lines.push(`\`\`\`json\n${JSON.stringify(diagnostic, null, 2)}\n\`\`\``);
}
}
if ((report.diagnostics?.staleElectronProcesses?.length ?? 0) > 0) {
lines.push('', '## Cleaned Stale Electron Processes', '');
for (const processInfo of report.diagnostics.staleElectronProcesses) {
lines.push(
`- pid=${processInfo.pid} ppid=${processInfo.ppid} command=\`${escapeMd(processInfo.command)}\``,
);
}
}
lines.push('');
return `${lines.join('\n')}\n`;
}
function summarizeLayer(checks) {
if (checks.every((check) => check.status === 'PASS')) return 'PASS';
if (checks.some((check) => check.status === 'FAIL')) return 'FAIL';
return 'UNVERIFIED';
}
function escapeMd(value) {
return String(value).replaceAll('|', '\\|').replaceAll('\n', '<br>');
}
// Bounded stop through the shared Electron lifecycle owner: SIGTERM as the
// graceful close, then the same 5s-grace → SIGKILL-the-tree escalation every
// other launcher uses. A wedged Electron must cost seconds, not a hung gate.
async function stopElectron(child) {
try {
if (child.exitCode === null && child.signalCode === null) {
if (process.platform === 'win32') {
await terminateChildProcessTree(child, 'SIGKILL');
await new Promise((resolveExit) => {
if (child.exitCode !== null || child.signalCode !== null) {
resolveExit();
return;
}
const timeout = setTimeout(resolveExit, 2_000);
child.once('exit', () => {
clearTimeout(timeout);
resolveExit();
});
});
if (child.exitCode === null && child.signalCode === null) {
throw new Error('Electron process tree did not exit after taskkill');
}
return;
}
await closeElectronApplication(
{
close: () =>
new Promise((resolve) => {
child.once('exit', resolve);
child.kill('SIGTERM');
}),
process: () => child,
},
5_000,
);
}
} finally {
child.stdout?.destroy();
child.stderr?.destroy();
}
}
// Launch-only mode. The checklist and the report belong to the smoke gate;
// looking at a route in the running app needs neither, and a migration that
// must stay visually neutral needs to look constantly. Same launch path as
// the gate, so the window a reviewer inspects is the window the gate checks.
async function runManualLaunch(args, diagnostics) {
console.log(
`[real-window-smoke] manual launch: scenario=${args.scenario} viewport=${args.width}x${args.height}`,
);
const launchInfo = await launchElectron(args, diagnostics);
if (!launchInfo?.child) {
console.error('[real-window-smoke] manual launch requires a window; --no-launch is redundant.');
process.exit(2);
}
console.log('[real-window-smoke] close the window or press Ctrl-C to stop it.');
await new Promise((resolve) => {
const stop = () => {
void stopElectron(launchInfo.child);
};
process.on('SIGINT', stop);
process.on('SIGTERM', stop);
launchInfo.child.on('exit', resolve);
});
}
async function main() {
const args = parseArgs(process.argv);
if (args.help) {
printHelp();
return;
}
await ensureRepoRoot();
const diagnostics = {
argv: process.argv.slice(2),
staleElectronProcesses: await cleanupStaleElectronProcesses(args.cleanupStale),
};
if (args.manual) {
await runManualLaunch(args, diagnostics);
return;
}
console.log('[real-window-smoke] This gate requires a human to test native window behavior.');
console.log(
'[real-window-smoke] Build first via `npm --workspace @maka/desktop run smoke:real-window`.',
);
console.log(
`[real-window-smoke] scenario=${args.scenario} viewport=${args.width}x${args.height}`,
);
const launchInfo = await launchElectron(args, diagnostics);
let report;
try {
if (launchInfo?.waitForDiagnostics) {
await launchInfo.waitForDiagnostics(
Number.isFinite(args.diagnosticWaitMs) ? args.diagnosticWaitMs : 1500,
'settled-1000ms',
);
diagnostics.windowDiagnosticObserved = (diagnostics.windowDiagnostics?.length ?? 0) > 0;
}
const programmaticResults = args.startupOnly
? buildStartupResults(diagnostics)
: buildProgrammaticResults(args, diagnostics);
const results =
args.programmaticOnly || args.startupOnly
? programmaticResults
: [
...programmaticResults,
...(args.unverifiedNote !== null
? buildUnverifiedOsHitTestResults(args)
: await promptChecks(args)),
];
report = await writeReport(args, launchInfo, results, diagnostics);
} finally {
// Reclaim the window even when a later step throws — a failed report
// write must not leave a live Electron behind.
try {
if (launchInfo?.child) await stopElectron(launchInfo.child);
} finally {
if (args.startupOnly && launchInfo?.userDataDir) {
await rm(launchInfo.userDataDir, { recursive: true, force: true });
}
}
}
process.exit(report.ok ? 0 : 1);
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((err) => {
console.error('[real-window-smoke] fatal:', err);
process.exit(1);
});
}