Preserve desktop startup, background work and shutdown

Preserve the reviewed user-facing behavior, public-edition boundaries and owning regression coverage.

Prompt audit: Keep existing permission, ownership and recovery contracts at their current tool, runtime or test owner (Hard constraint). No separate resident instruction is added. Drop excluded hosted-only branches; retain shared side effects and tests. No optional prompt rewrite.

Prompt confirmation: The requester confirmed the exact proposed final behaviors and existing resource placements by directing that the primary implementation be followed, then explicitly requested this PR and merge. Preserve that confirmed scope only.

- Keep: The behavior and placement detailed in the audit above. Type: Hard constraint. Why: Preserve its existing canonical owner without duplicated resident instructions.
- Drop: Superseded overlapping wording and excluded hosted-only behavior. Type: Hard constraint. Why: Preserve the confirmed public capability and authority boundary.

Confirmed-by: Requester, exact final-state proposal and subsequent delivery request.
Scope: The previously confirmed final behavior and existing placement described above; no optional prompt rewrite.
This commit is contained in:
wyt committed 2026-11-04 23:22:49 +08:00
1 parent 563efdf497
commit 52a8050743
26 files changed
+1722 -75

No files matched your search

+1
View File
@@ -115,6 +115,7 @@ const PACKAGED_BIN_HELPERS = Object.freeze([
// Shared automation schema loaded by both the packaged Orkas bridge and
// the in-process auto_tasks tool.
'auto-tasks-contract.cjs',
'chat-history-contract.cjs',
'project-instructions-contract.cjs',
'browser-tool-contract.cjs',
'bridge-skill-runner.cjs',
+31
View File
@@ -0,0 +1,31 @@
import type { BrowserWindow, WebContents } from 'electron';
interface Dependencies {
canHide(): boolean;
/** Standalone Linux retains the native close owner because it has no tray. */
nativeClose?(): boolean;
isShutdownComplete(): boolean;
}
/** Closing only hides the main window. Explicit quit and update own shutdown. */
export function createDesktopWindowController(deps: Dependencies) {
const windows = new Set<BrowserWindow>();
return {
owns(sender: WebContents) { return [...windows].some(win => !win.isDestroyed() && win.webContents === sender); },
install(win: BrowserWindow, isQuitting = () => false) {
windows.add(win);
win.on('close', event => {
// Squirrel closes windows before before-quit. Prepared updates must
// destroy them, while ordinary close never starts shutdown preparation.
if (deps.nativeClose?.() || isQuitting() || deps.isShutdownComplete()) return;
event.preventDefault();
// Without a Windows tray the visible window is the recovery entry.
if (deps.canHide()) win.hide();
});
win.on('closed', () => { windows.delete(win); });
},
};
}
let controller: ReturnType<typeof createDesktopWindowController> | null = null;
export function setDesktopWindowController(value: ReturnType<typeof createDesktopWindowController>) { controller = value; }
export function getDesktopWindowController() { return controller; }
+77
View File
@@ -0,0 +1,77 @@
import { randomUUID } from 'node:crypto';
import { readDeviceRecord, updateDeviceRecord } from '../util/device-record';
export const STARTUP_ARGUMENT = '--orkas-startup';
interface Dependencies {
platform: NodeJS.Platform;
available: boolean;
executable: string;
get(options?: { path: string; args: string[] }): { openAtLogin: boolean; status?: string; executableWillLaunchAtLogin?: boolean };
set(settings: { openAtLogin: boolean; path?: string; args?: string[]; enabled?: boolean }): void;
getUser(): string;
hasEnabledAutomations(uid: string): Promise<boolean>;
}
export interface StartupState { enabled: boolean; available: boolean; status: 'enabled' | 'disabled' | 'approval' | 'unavailable' }
const failure = (code: string) => Object.assign(new Error(code), { code });
/** The OS owns startup consent. The device record owns only the one-time UI marker. */
export function createLoginStartup(deps: Dependencies) {
let pending: { id: string; owner: number; uid: string; expires: number } | null = null;
const available = deps.available && ['darwin', 'win32'].includes(deps.platform);
const options = () => deps.platform === 'win32' ? { path: deps.executable, args: [STARTUP_ARGUMENT] } : undefined;
const read = (): StartupState => {
if (!available) return { enabled: false, available: false, status: 'unavailable' };
try {
const lookup = options();
// Electron 42 parses the Windows lookup as a command line. Quote the
// executable so spaced install paths still match effective launch items.
const native = deps.get(lookup ? { ...lookup, path: `"${deps.executable}"` } : undefined);
if (deps.platform === 'darwin' && native.status === 'requires-approval') return { enabled: false, available: true, status: 'approval' };
const enabled = native.openAtLogin === true && (deps.platform === 'darwin'
? (!native.status || native.status === 'enabled') : native.executableWillLaunchAtLogin === true);
return { enabled, available: true, status: enabled ? 'enabled' : 'disabled' };
} catch { throw failure('E_STARTUP_READ'); }
};
const setEnabled = (enabled: boolean) => {
if (!available) throw failure('E_STARTUP_UNAVAILABLE');
if (typeof enabled !== 'boolean') throw failure('E_STARTUP_INPUT');
try {
deps.set({ ...options(), openAtLogin: enabled, ...(deps.platform === 'win32' ? { enabled } : {}) });
const result = read();
if (result.enabled !== enabled && !(enabled && result.status === 'approval')) throw failure('E_STARTUP_SAVE');
return result;
} catch { throw failure('E_STARTUP_SAVE'); }
};
const releasePrompt = (owner: number) => { if (pending?.owner === owner) pending = null; };
const claimPrompt = async (owner: number, uid: string) => {
try {
if (pending && pending.expires <= Date.now()) pending = null;
if (pending && pending.uid !== deps.getUser()) pending = null;
if (pending || uid !== deps.getUser() || read().status !== 'disabled' || readDeviceRecord().desktop?.startup_prompt_shown === true) return null;
let timer: ReturnType<typeof setTimeout> | undefined;
const hasEnabled = await Promise.race([
deps.hasEnabledAutomations(uid),
new Promise<never>((_, reject) => { timer = setTimeout(() => reject(failure('E_STARTUP_READ')), 5000); }),
]).finally(() => clearTimeout(timer));
if (!hasEnabled) return null;
// Another renderer or account can win while authoritative tasks are read.
if (pending || uid !== deps.getUser() || read().status !== 'disabled' || readDeviceRecord().desktop?.startup_prompt_shown === true) return null;
const id = randomUUID(); pending = { id, owner, uid, expires: Date.now() + 30_000 };
return id;
} catch { throw failure('E_STARTUP_READ'); }
};
const markPromptShown = (owner: number, uid: string, id: unknown) => {
if (!pending || pending.owner !== owner || pending.uid !== uid || uid !== deps.getUser() || pending.id !== id || pending.expires <= Date.now()) return false;
try {
updateDeviceRecord(record => ({ ...record, desktop: { ...record.desktop, startup_prompt_shown: true } }));
pending = null; return true;
} catch { throw failure('E_STARTUP_SAVE'); }
};
return { read, setEnabled, claimPrompt, markPromptShown, releasePrompt };
}
export function shouldHideStartupWindow(input: { platform: NodeJS.Platform; argv: string[]; wasOpenedAtLogin: boolean }): boolean {
return (input.platform === 'darwin' ? input.wasOpenedAtLogin : input.platform === 'win32' && input.argv.includes(STARTUP_ARGUMENT));
}
let service: ReturnType<typeof createLoginStartup> | null = null;
export function setLoginStartup(value: ReturnType<typeof createLoginStartup>) { service = value; }
export function getLoginStartup() { if (!service) throw failure('E_STARTUP_UNAVAILABLE'); return service; }
+5 -19
View File
@@ -10,7 +10,7 @@
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { updateDeviceRecord } from '../util/device-record';
import { DEVICE_FILE } from '../paths';
import { createLogger } from '../logger';
@@ -53,24 +53,10 @@ function readStoredDeviceId(): string {
}
function writeStoredDeviceId(deviceId: string): void {
const dir = path.dirname(DEVICE_FILE);
fs.mkdirSync(dir, { recursive: true });
const tmp = `${DEVICE_FILE}.${process.pid}.${Date.now()}.tmp`;
try {
const body = JSON.stringify({
version: 1,
device_id: deviceId,
created_at: Date.now(),
}, null, 2);
fs.writeFileSync(tmp, body, { encoding: 'utf8', mode: 0o600 });
fs.renameSync(tmp, DEVICE_FILE);
} finally {
// A failed rename (read-only path, directory collision, full disk) must
// not accumulate machine-global identity temp files on every launch.
try {
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
} catch { /* best-effort cleanup; the original persistence error wins */ }
}
updateDeviceRecord(record => ({
...record, version: 1, device_id: deviceId,
created_at: record.created_at ?? Date.now(),
}), true);
}
export function getDeviceId(legacySeed?: unknown): string {
+19 -10
View File
@@ -1,33 +1,42 @@
import { nativeImage, Tray } from 'electron';
import { Menu, nativeImage, Tray } from 'electron';
type TrayOptions = {
platform: NodeJS.Platform;
iconPath: string;
displayName: string;
labels: () => { open: string; quit: string };
onActivate: () => void;
onQuit: () => void;
warn: (message: string) => void;
};
/** The tray is optional: failure must not interrupt the already-created main window. */
export function createOptionalTray({ platform, iconPath, displayName, onActivate, warn }: TrayOptions): Tray | null {
export function createOptionalTray({ platform, iconPath, displayName, labels, onActivate, onQuit, warn }: TrayOptions): Tray | null {
if (platform !== 'darwin' && platform !== 'win32') return null;
const warnSafely = (message: string): void => {
try { warn(message); } catch { /* diagnostic failure cannot make the tray mandatory */ }
};
const action = (run: () => void, message: string) => () => {
try { run(); } catch { warnSafely(message); }
};
let tray: Tray | null = null;
try {
const image = nativeImage.createFromPath(iconPath);
if (image.isEmpty()) {
warnSafely('tray icon unavailable');
return null;
}
if (image.isEmpty()) { warnSafely('tray icon unavailable'); return null; }
if (platform === 'darwin') image.setTemplateImage(true);
tray = new Tray(image);
tray.setToolTip(displayName);
tray.on('click', () => {
try { onActivate(); } catch { warnSafely('tray activation unavailable'); }
});
// Build on each opening so account/language changes cannot leave stale copy.
// Own both mouse buttons; no second automatic context menu is registered.
const openMenu = action(() => {
const text = labels();
tray!.popUpContextMenu(Menu.buildFromTemplate([
{ label: text.open, click: action(onActivate, 'tray activation unavailable') },
{ label: text.quit, click: action(onQuit, 'tray quit unavailable') },
]));
}, 'tray menu unavailable');
tray.on('click', openMenu);
tray.on('right-click', openMenu);
return tray;
} catch {
try { tray?.destroy(); } catch { /* best-effort cleanup of a partial tray */ }
+25
View File
@@ -0,0 +1,25 @@
/** One synchronous read/modify/atomic-write owner for machine identity and desktop preferences. */
import * as fs from 'node:fs';
import * as path from 'node:path';
import { randomUUID } from 'node:crypto';
import { DEVICE_FILE } from '../paths';
export function readDeviceRecord(): Record<string, any> {
let raw: string;
try { raw = fs.readFileSync(DEVICE_FILE, 'utf8'); }
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}; throw error; }
const record = JSON.parse(raw);
if (!record || typeof record !== 'object' || Array.isArray(record)) throw new SyntaxError('Invalid device record');
return record;
}
export function updateDeviceRecord(update: (record: Record<string, any>) => Record<string, any>, recoverInvalidIdentity = false): void {
let record: Record<string, any>;
try { record = readDeviceRecord(); }
catch (error) { if (recoverInvalidIdentity && error instanceof SyntaxError) record = {}; else throw error; }
const next = update(record);
fs.mkdirSync(path.dirname(DEVICE_FILE), { recursive: true });
const tmp = `${DEVICE_FILE}.${process.pid}.${randomUUID()}.tmp`;
try {
fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { encoding: 'utf8', mode: 0o600 });
fs.renameSync(tmp, DEVICE_FILE);
} finally { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); }
}
+91
View File
@@ -0,0 +1,91 @@
// Device-scoped OS startup consent. Guidance is offered only on an Automation tab visit.
(() => {
const toggle = document.getElementById('settings-startup-toggle');
const status = document.getElementById('settings-startup-status');
const settingsLink = document.getElementById('settings-startup-open-settings');
let state = null, revision = 0, saving = false, checking = false, activePrompt = null;
const invoke = (channel, payload) => window.orkas.invoke(channel, payload || {});
const errorText = code => t(code === 'E_STARTUP_UNAVAILABLE' ? 'settings.startup.unavailable' : code === 'E_STARTUP_READ' ? 'settings.startup.read_failed' : 'settings.startup.save_failed');
const paint = () => {
if (toggle) { toggle.checked = state?.enabled === true; toggle.disabled = saving || !state?.available; }
if (status) {
const key = !state ? 'read_failed' : state.status === 'approval' ? 'approval' : '';
status.textContent = key ? t(`settings.startup.${key}`) : '';
status.hidden = !key;
}
if (settingsLink) {
settingsLink.hidden = state?.status !== 'approval';
settingsLink.style.display = settingsLink.hidden ? 'none' : '';
}
};
async function refresh() {
if (saving) return;
const token = ++revision;
try {
const result = await invoke('desktop.getStartup');
if (token !== revision || saving) return;
if (!result.ok || typeof result.startup?.enabled !== 'boolean') throw new Error();
state = result.startup;
} catch { if (token !== revision) return; state = null; }
paint();
}
async function enable(value) {
++revision; saving = true; paint();
try {
const result = await invoke('desktop.setStartup', { enabled: value });
if (!result.ok || !result.startup) return errorText(result.code);
state = result.startup;
if (state.status === 'approval') uiToast(t('settings.startup.approval'));
return null;
} catch { return errorText('E_STARTUP_SAVE'); }
finally { saving = false; paint(); }
}
toggle?.addEventListener('change', async () => {
if (saving) return;
const error = await enable(toggle.checked);
// Read back even after a failure: the OS might have accepted a partial change.
if (error) { uiToast(error, { variant: 'error' }); await refresh(); }
});
settingsLink?.addEventListener('click', async () => {
try { const result = await invoke('desktop.openStartupSettings'); if (!result.ok) throw new Error(); }
catch { uiToast(t('settings.startup.open_failed'), { variant: 'error' }); }
});
async function visitAutomation() {
if (checking || activePrompt || !document.getElementById('panel-auto')?.classList.contains('active') || document.querySelector('.modal-overlay.open')) return;
checking = true;
let claimed = false;
try {
const result = await invoke('desktop.claimStartupPrompt');
if (!result.ok || !result.id) return;
claimed = true;
if (!document.getElementById('panel-auto')?.classList.contains('active') || document.querySelector('.modal-overlay.open')) return;
const abort = new AbortController(); activePrompt = abort;
let acknowledged;
// uiChoice appends the standard modal synchronously before returning its promise.
const choice = uiChoice({
title: t('settings.startup.prompt_title'),
message: t('settings.startup.prompt_body') + '\n' + t('settings.startup.prompt_settings'),
cancelLabel: t('settings.startup.later'),
choices: [{ id: 'enable-startup', label: t('settings.startup.enable') }],
signal: abort.signal,
onChoice: async () => {
if (!await acknowledged) return errorText('E_STARTUP_SAVE');
return enable(true);
},
});
acknowledged = invoke('desktop.markStartupPromptShown', { id: result.id }).then(response => response.ok && response.shown === true, () => false);
if (!await acknowledged) { abort.abort(); return; }
await choice;
} catch {
// A failed lookup must leave guidance eligible on a later tab visit.
createLogger('desktop-startup').warn('startup guidance unavailable');
} finally {
checking = false; activePrompt = null;
if (claimed) { try { await invoke('desktop.releaseStartupPrompt'); } catch { /* lease also expires */ } }
}
}
window.DesktopStartup = { visitAutomation };
window.addEventListener('focus', () => { void refresh(); });
window.addEventListener('i18n-change', paint);
void refresh();
})();
+1 -1
View File
@@ -200,7 +200,7 @@ for (const [boundary, continuation] of [
.createChatHistoryTool({ userId: users.getActiveUserId(), currentCid: data.cid, currentMessageId: data.boundaryId,
allowedScopes: ['current'] });
return history.execute({ action: 'read', scope: 'current',
page: { mode: 'latest', count: 30 }, include_process: true, max_tokens: 10000 }, { state: {} });
read: { page: { mode: 'latest', count: 30 }, include_process: true }, max_tokens: 10000 }, { state: {} });
}, { root: path.resolve(__dirname, '../..'), cid,
boundaryId: records().filter(row => row.from === 'user').at(-1).id });
expect(retrieved.isError, retrieved.content).toBeFalsy();
@@ -0,0 +1,58 @@
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
import path from 'node:path';
import { expect, test } from './fixtures/orkas';
import { trayMenu, selectTrayItem } from './fixtures/desktop-tray';
import type { Page } from '@playwright/test';
async function settings(page: Page) {
await page.locator('#settings-btn').click();
await page.locator('[data-settings-tab="general"]').click();
await expect(page.locator('#settings-startup-row')).toBeVisible();
await expect(page.locator('#settings-background-row')).toHaveCount(0);
}
test('first and repeated closes preserve the same renderer and draft; legacy settings cannot disable background running', async ({ orkas }, info) => {
const page = orkas.page!, electron = orkas.electronApp!;
await page.locator('#new-chat-input').fill('Preserve this unsent draft');
await settings(page);
await page.screenshot({ path: info.outputPath('settings-en.png') });
const file = path.join(orkas.workspaceRoot, 'device.json');
const original = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
if (!original.device_id) original.device_id = 'preserved-legacy-device';
const legacy = { ...original, future: { preserved: true }, desktop: { ...original.desktop,
background_enabled: false, close_choice_made: false, startup_prompt_shown: true, future: 'preserved' } };
writeFileSync(file, JSON.stringify(legacy));
const native = await electron.browserWindow(page);
await native.evaluate(win => { (win as any).__shows = 0; win.on('show', () => { (win as any).__shows++; }); });
for (let i = 0; i < 2; i++) {
await native.evaluate(win => win.close());
await expect(page.locator('.ui-dialog-overlay.open')).toHaveCount(0);
expect(await native.evaluate(win => ({ destroyed: win.isDestroyed(), visible: win.isVisible(), shows: (win as any).__shows })))
.toEqual({ destroyed: false, visible: false, shows: 0 });
expect(await trayMenu(electron, i === 0 ? 'click' : 'right-click')).toEqual([
{ label: 'Open', type: 'normal', enabled: true }, { label: 'Quit', type: 'normal', enabled: true }]);
await selectTrayItem(electron, 0);
await expect(page.locator('#new-chat-input')).toHaveText('Preserve this unsent draft');
}
expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual(legacy);
await orkas.invoke('config.setLanguage', { language: 'zh' }); await page.reload(); await settings(page);
expect((await trayMenu(electron)).map(item => item.label)).toEqual(['打开', '退出']);
await page.setViewportSize({ width: 860, height: 640 });
await expect(page.locator('#settings-startup-description')).toHaveText('电脑开机后,自动启动 Orkas。');
const bounds = await page.locator('#settings-startup-row').boundingBox();
expect(bounds!.x).toBeGreaterThanOrEqual(0); expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(860);
await page.screenshot({ path: info.outputPath('settings-zh-narrow.png') });
expect(await native.evaluate(win => (win as any).__shows)).toBe(0);
const reopened = await orkas.relaunch(); await settings(reopened);
expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual(legacy);
});
test('corrupt legacy preferences cannot prevent closing to background or create a warning', async ({ orkas }) => {
const file = path.join(orkas.workspaceRoot, 'device.json');
const original = existsSync(file) ? readFileSync(file, 'utf8') : null;
try {
writeFileSync(file, '{invalid');
const native = await orkas.electronApp!.browserWindow(orkas.page!);
await native.evaluate(win => win.close());
expect(await native.evaluate(win => ({ alive: !win.isDestroyed(), hidden: !win.isVisible() }))).toEqual({ alive: true, hidden: true });
await expect(orkas.page!.locator('.ui-dialog-overlay.open')).toHaveCount(0);
expect(readFileSync(file, 'utf8')).toBe('{invalid');
} finally { if (original === null) unlinkSync(file); else writeFileSync(file, original); }
});
@@ -0,0 +1,218 @@
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { createServer } from 'node:http';
import { expect, test, type OrkasTestApp } from './fixtures/orkas';
import { trayMenu, selectTrayItem } from './fixtures/desktop-tray';
const closeDialog = (app: OrkasTestApp) => app.page!.locator('.ui-dialog-overlay').filter({ has: app.page!.locator('[data-id="background"]') });
const records = (file: string): any[] => existsSync(file) ? readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line)) : [];
const toolReceipts = (rows: any[]) => rows.flatMap(row => row.process || []).filter(item => item.event?.stream === 'tool'
&& item.event.data?.phase === 'end' && item.event.data?.id === 'call-e2e-commentary-1');
async function closeToBackground(app: OrkasTestApp) {
const native = await app.electronApp!.browserWindow(app.page!);
await native.evaluate(win => { (win as any).__shows = 0; win.on('show', () => { (win as any).__shows++; }); win.close(); });
await expect(closeDialog(app)).toHaveCount(0);
await expect.poll(() => native.evaluate(win => win.isVisible())).toBe(false);
return native;
}
async function pausedTask(app: OrkasTestApp) {
const input = app.createWorkspaceFile('lifecycle-proof.txt', 'BACKGROUND_TASK_FACT=retained\n');
await app.setChatCommentaryScenario({ reads: [{ path: input, commentary: 'Checking the task information.' }], finalText: 'Background task completed.' });
const page = app.page!;
await page.locator('#new-chat-btn').click(); await page.locator('#new-chat-input').fill('Read the task information and report the result.');
await page.locator('#new-chat-send-btn').click();
await expect.poll(() => app.commentaryStage, { timeout: 30_000 }).toBe(1); app.releaseCommentaryStage();
await expect.poll(() => app.commentaryStage, { timeout: 30_000 }).toBe(2);
const cid = await page.evaluate<string>('currentCid');
return { cid, file: path.join(app.workspaceRoot, 'account-e2e/cloud/chats', `${cid}.jsonl`) };
}
function alive(pid: number) {
try { process.kill(pid, 0); return true; }
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false; throw error; }
}
test('a task completes and an automation fires exactly once after the user closes to background', async ({ modelOrkas: app }, info) => {
test.setTimeout(90_000);
const task = await pausedTask(app);
// The user schedules work before closing; the real scheduler must admit it
// after the window is closed, without a renderer creating the task then.
const at = new Date(Date.now() + 10_000).toISOString();
const enabled = await app.invoke<any>('autoTasks.create', { title: 'Background enabled automation', content: 'Summarize the task information.', enabled: true, schedule: { type: 'one_time', at } });
const disabled = await app.invoke<any>('autoTasks.create', { title: 'Background disabled automation', content: 'This task is disabled.', enabled: false, schedule: { type: 'one_time', at } });
const mainPid = app.electronApp!.process().pid;
const native = await closeToBackground(app);
// Hidden-at-launch E2E has no physical show-to-hide transition. Feed the
// native lifecycle event through the shipped presentation listener, keeping
// every real window hidden while testing suspended transcript recovery.
await native.evaluate(win => win.emit('hide'));
await expect.poll(() => app.page!.evaluate<boolean>('_conversationWindowActive')).toBe(false);
expect(alive(mainPid!)).toBe(true);
app.releaseCommentaryStage();
await expect.poll(() => records(task.file).some(row => row.text === 'Background task completed.'), { timeout: 20_000 }).toBe(true);
expect(toolReceipts(records(task.file))).toHaveLength(1);
expect(records(task.file).filter(row => row.system_kind === 'reply_interrupted')).toHaveLength(0);
app.clearModelToolScenario(); app.setModelTextReplies(['Background automation completed.']);
await expect.poll(async () => {
const result = await app.invoke<any>('autoTasks.list');
return result.tasks.find((row: any) => row.id === enabled.task.id)?.last_run?.status;
}, { timeout: 30_000 }).toBe('completed');
const list = await app.invoke<any>('conversations.list');
const fired = list.conversations.filter((row: any) => row.origin_auto_task_id === enabled.task.id);
expect(fired).toHaveLength(1);
expect(list.conversations.filter((row: any) => row.origin_auto_task_id === disabled.task.id)).toHaveLength(0);
const automatedFile = path.join(app.workspaceRoot, 'account-e2e/cloud/chats', `${fired[0].conversation_id}.jsonl`);
expect(records(automatedFile).filter(row => row.text === 'Background automation completed.')).toHaveLength(1);
const tasks = (await app.invoke<any>('autoTasks.list')).tasks;
expect(tasks.find((row: any) => row.id === enabled.task.id)).toMatchObject({ enabled: false, run_count: 1 });
expect(tasks.find((row: any) => row.id === disabled.task.id).last_run).toBeUndefined();
expect(app.modelRequests).toHaveLength(3);
expect(await native.evaluate(win => ({ destroyed: win.isDestroyed(), visible: win.isVisible(), shows: (win as any).__shows }))).toEqual({ destroyed: false, visible: false, shows: 0 });
expect(app.electronApp!.process().pid).toBe(mainPid);
expect((await trayMenu(app.electronApp!)).map(item => item.label)).toEqual(['Open', 'Quit']);
await selectTrayItem(app.electronApp!, 0);
await native.evaluate(win => win.emit('show'));
await expect(app.page!.locator('#chat-history .chat-message.assistant')).toContainText('Background task completed.');
await app.page!.evaluate(cid => (window as any).setView('conversation', cid), fired[0].conversation_id);
await expect(app.page!.locator('#chat-history .chat-message.assistant')).toContainText('Background automation completed.');
expect(app.modelRequests).toHaveLength(3);
expect(await native.evaluate(win => win.isVisible())).toBe(false);
await info.attach('background-execution-proof', { body: JSON.stringify({ sameProcess: true, taskReceiptCount: 1, automationCompleted: 1, disabledRuns: 0, nativeShows: 0 }), contentType: 'application/json' });
});
for (const entry of ['user task', 'automation'] as const) {
test(`a ${entry} fills and navigates its browser after closing without waking windows or duplicating website actions`, async ({ modelOrkas: app }, info) => {
test.setTimeout(90_000);
const submissions: Array<{ path: string; value: string; trusted: boolean }> = [];
const server = createServer((req, res) => {
if (req.method === 'POST') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => { submissions.push({ path: req.url!, ...JSON.parse(body) }); res.end('ok'); });
return;
}
res.setHeader('Content-Type', 'text/html');
res.end(`<!doctype html><title>Background report</title><label>Report<input id="report"></label>
<button id="save">Save report</button><p id="state">Pending</p><script>
save.onclick=async event=>{await fetch(location.pathname,{method:'POST',body:JSON.stringify({value:report.value,trusted:event.isTrusted})});state.textContent='Saved '+report.value};
</script>`);
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
try {
let task: { cid: string; file: string } | undefined;
let automationId: string | undefined;
if (entry === 'user task') task = await pausedTask(app);
else {
const input = app.createWorkspaceFile('automation-browser-proof.txt', 'BACKGROUND_BROWSER_FACT=retained\n');
await app.setChatCommentaryScenario({ reads: [{ path: input, commentary: 'Checking the automation information.' }], finalText: 'Background task completed.' });
const scheduled = await app.invoke<any>('autoTasks.create', { title: 'Unattended browser task', content: 'Prepare the report.', enabled: true,
schedule: { type: 'one_time', at: new Date(Date.now() + 1500).toISOString() } });
automationId = scheduled.task.id;
}
const native = await closeToBackground(app);
await native.evaluate(win => win.emit('hide'));
await expect.poll(() => app.page!.evaluate<boolean>('_conversationWindowActive')).toBe(false);
if (entry === 'automation') {
await expect.poll(() => app.commentaryStage, { timeout: 30_000 }).toBe(1); app.releaseCommentaryStage();
await expect.poll(() => app.commentaryStage, { timeout: 30_000 }).toBe(2);
const list = await app.invoke<any>('conversations.list');
const fired = list.conversations.filter((row: any) => row.origin_auto_task_id === automationId);
expect(fired).toHaveLength(1);
task = { cid: fired[0].conversation_id, file: path.join(app.workspaceRoot, 'account-e2e/cloud/chats', `${fired[0].conversation_id}.jsonl`) };
}
const currentTask = task!;
const root = path.resolve(__dirname, '../..');
// Capture the real bus-owned task lifetime; no fake browser run is begun.
await app.electronApp!.evaluate(async (_e, args) => {
await new Promise<void>(resolve => setImmediate(resolve));
const req = (process as any).mainModule.require;
(globalThis as any).__closedTaskBrowser = req(args.root + '/src/main/features/group_chat/browser_tool.ts')
.buildConversationBrowserTool('account-e2e', args.cid);
}, { root, cid: currentTask.cid });
const call = (input: Record<string, unknown>) => app.electronApp!.evaluate(async (_e, input) => {
await new Promise<void>(resolve => setImmediate(resolve));
return JSON.parse((await (globalThis as any).__closedTaskBrowser.execute(input, {})).content);
}, input);
const opened = await call({ operation: 'open', url: origin + '/first' });
expect(opened).toMatchObject({ ok: true });
const tab_id = opened.active_tab_id;
const act = async (label: string, page_action: string, text?: string) => {
const observed = await call({ operation: 'observe', tab_id });
expect(observed).toMatchObject({ ok: true });
const element = observed.elements.find((row: any) => row.label === label);
expect(element).toBeTruthy();
const input = { operation: 'act', tab_id, page_id: observed.page_id, element_ref: element.ref, page_action, ...(text === undefined ? {} : { text }) };
expect(await call(input)).toMatchObject({ ok: true });
return input;
};
for (const [route, value] of [['first', '后台报告一'], ['second', 'Background report two']]) {
expect(await call({ operation: 'wait', tab_id })).toMatchObject({ ok: true });
await act('Report', 'fill', value);
const previousClick = await act('Save report', 'click');
expect(await call({ operation: 'wait', tab_id, wait_condition: 'text', text: 'Saved ' + value })).toMatchObject({ ok: true });
expect(submissions).toHaveLength(route === 'first' ? 1 : 2);
expect(submissions.at(-1)).toEqual({ path: '/' + route, value, trusted: true });
if (route === 'first') {
expect(await call({ operation: 'navigate', tab_id, navigation: 'goto', url: origin + '/second' })).toMatchObject({ ok: true });
expect(await call({ operation: 'wait', tab_id })).toMatchObject({ ok: true });
// A stale snapshot must not repeat a website write after navigation.
expect(await call(previousClick)).toMatchObject({ ok: false });
expect(submissions).toHaveLength(1);
}
expect(await native.evaluate(win => ({ visible: win.isVisible(), focused: win.isFocused(), shows: (win as any).__shows })))
.toEqual({ visible: false, focused: false, shows: 0 });
expect(await app.electronApp!.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().some(win => win.isVisible()))).toBe(false);
}
app.releaseCommentaryStage();
await expect.poll(() => records(currentTask.file).some(row => row.text === 'Background task completed.'), { timeout: 20_000 }).toBe(true);
expect(toolReceipts(records(currentTask.file))).toHaveLength(1);
await expect.poll(async () => (await call({ operation: 'tabs' })).code).toBe('task_run_ended');
if (automationId) {
await expect.poll(async () => (await app.invoke<any>('autoTasks.list')).tasks.find((row: any) => row.id === automationId))
.toMatchObject({ enabled: false, run_count: 1, last_run: { status: 'completed' } });
}
expect(app.modelRequests).toHaveLength(2);
await info.attach('closed-browser-execution-proof', { body: JSON.stringify({ nativeShows: 0, submissions, taskReceipts: 1, endedToolRevoked: true }), contentType: 'application/json' });
} finally {
server.closeAllConnections();
await new Promise<void>(resolve => server.close(() => resolve()));
}
});
}
for (const entry of ['tray-exit', 'native-quit'] as const) {
test(`${entry} terminates the real main/helper processes and archives active task evidence`, async ({ modelOrkas: app }, info) => {
test.setTimeout(90_000);
await closeToBackground(app);
const task = await pausedTask(app);
await app.invoke('autoTasks.create', { title: 'Future automation at exit', content: 'Prepare a report.', enabled: true,
schedule: { type: 'one_time', at: '2099-12-01T10:00:00.000Z' } });
const deviceFile = path.join(app.workspaceRoot, 'device.json');
const beforeDevice = JSON.parse(readFileSync(deviceFile, 'utf8'));
if (entry === 'tray-exit') expect((await trayMenu(app.electronApp!)).map(item => item.label)).toEqual(['Open', 'Quit']);
const electron = app.electronApp!;
const child = electron.process();
const pids = await electron.evaluate(({ app }) => [...new Set([process.pid, ...app.getAppMetrics().map(row => row.pid)])]);
if (entry === 'native-quit' && process.platform === 'darwin') {
// Native Dock Quit and application-menu Quit both enter Electron's app.quit.
expect(await electron.evaluate(({ Menu }) => Menu.getApplicationMenu()?.items[0].submenu?.items.some(item => item.role === 'quit'))).toBe(true);
}
await app.prepareForNativeShutdown();
const closed = app.page!.waitForEvent('close');
const exitStarted = Date.now();
if (entry === 'tray-exit') await selectTrayItem(electron, 1);
else await electron.evaluate(({ app }) => { setImmediate(() => app.quit()); });
await closed;
await expect.poll(() => child.exitCode, { timeout: 15_000 }).toBe(0);
const exitMs = Date.now() - exitStarted;
await expect.poll(() => pids.filter(alive), { timeout: 10_000 }).toEqual([]);
const rows = records(task.file);
expect(toolReceipts(rows)).toHaveLength(1);
expect(rows.filter(row => row.system_kind === 'reply_interrupted')).toHaveLength(1);
const device = JSON.parse(readFileSync(path.join(app.workspaceRoot, 'device.json'), 'utf8'));
expect(device).toEqual(beforeDevice);
expect(app.modelRequests).toHaveLength(2);
await info.attach('exit-process-proof', { body: JSON.stringify({ entry, exitMs, exitCode: child.exitCode, ownedProcessesExited: pids.length, archivedReceipts: 1, interruptedTurns: 1 }), contentType: 'application/json' });
});
}
+37
View File
@@ -0,0 +1,37 @@
import type { ElectronApplication } from '@playwright/test';
/** Execute the shipped tray + restore composition with the actual main window.
* Only the native popup is intercepted: hidden tests never display a menu. */
export async function trayMenu(electron: ElectronApplication, event: 'click' | 'right-click' = 'click') {
return electron.evaluate(({ app, BrowserWindow }, event) => {
const load = (process as any).mainModule.require.bind((process as any).mainModule);
const root = app.getAppPath(), fs = load('node:fs'), path = load('node:path'), vm = load('node:vm'), ts = load(path.join(root, 'node_modules/typescript'));
const source = fs.readFileSync(path.join(root, 'src/main/index.ts'), 'utf8');
const tree = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, true);
const names = ['createAppTray', 'showMainWindow'];
const functions = tree.statements.filter((node: any) => ts.isFunctionDeclaration(node) && names.includes(node.name?.text)).map((node: any) => node.getText(tree));
if (functions.length !== 2) throw new Error('Shipped tray composition missing');
const main = BrowserWindow.getAllWindows().find(w => w.webContents.getURL().endsWith('/renderer/index.html'))!;
const sandbox: any = { app, process, path, paths: load(path.join(root, 'src/main/paths.ts')), APP_DISPLAY_NAME: 'Orkas',
mainWindows: new Set([main]), createWindow: () => { throw new Error('Open must reuse the main window'); },
createOptionalTray: load(path.join(root, 'src/main/util/app-tray.ts')).createOptionalTray,
t: load(path.join(root, 'src/main/i18n.ts')).t, log: { warn: () => { throw new Error('Tray creation failed'); } },
setImmediate, appTray: null };
vm.runInNewContext(ts.transpileModule(functions.join('\n') + '\ncreateAppTray();', { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, sandbox);
const tray = sandbox.appTray;
if (!tray) throw new Error('Native tray was not created');
let menu: any;
try { tray.popUpContextMenu = (value: any) => { menu = value; }; tray.emit(event); }
finally { tray.destroy(); }
if (!menu) throw new Error('Tray click did not open a native menu');
(app as any).__desktopTestMenu = menu;
return menu.items.map((item: any) => ({ label: item.label, type: item.type, enabled: item.enabled }));
}, event);
}
export async function selectTrayItem(electron: ElectronApplication, index: 0 | 1) {
await electron.evaluate(({ app }, index) => {
const menu = (app as any).__desktopTestMenu;
if (!menu) throw new Error('Native tray menu not captured');
const item = menu.items[index]; item.click(undefined, undefined, {});
}, index);
}
+15 -7
View File
@@ -8,7 +8,7 @@ type OwnedApplication = {
context(): { tracing: { stop(options: { path: string }): Promise<void> } };
};
/** Run quit on the normal event loop, outside the inspector evaluation stack.
/** Detach the owned Node inspector before one normal quit outside evaluation.
* Keep Electron's before-quit flushes and wait for the actual application close. */
export async function requestElectronQuit(app: ElectronApplication): Promise<void> {
// Closing the final window already starts normal shutdown on Windows/Linux.
@@ -30,18 +30,26 @@ export async function requestElectronQuit(app: ElectronApplication): Promise<voi
app.once('close', onClose);
try {
try {
await app.evaluate(({ app }) => { setImmediate(() => app.quit()); });
await app.evaluate(({ app }) => {
const inspector = (process as any).mainModule.require('node:inspector') as { close(): void };
setImmediate(() => {
inspector.close();
app.quit();
});
});
} catch (error) {
// A fast successful exit may dispose the inspector before it replies.
// Playwright can report this before delivering the application close event.
// Still require that event below; the fixture's close deadline stays active.
const inspectorDisposed = error instanceof Error && error.message ===
'electronApplication.evaluate: Execution context was destroyed, most likely because of a navigation.';
const inspectorDisposed = error instanceof Error && [
'electronApplication.evaluate: Execution context was destroyed, most likely because of a navigation.',
'electronApplication.evaluate: Target page, context or browser has been closed',
].includes(error.message);
if (!closed && !inspectorDisposed) throw error;
}
// Complete the public Playwright close path after requesting deferred quit:
// it detaches the inspector and still waits for the actual process close.
await app.close();
// Playwright.close() evaluates app.quit() again before detaching. That
// second evaluation races Node teardown after the deferred quit above.
// The application's browser/process close remains the completion signal.
await completion;
} finally {
app.off('close', onClose);
+151
View File
@@ -0,0 +1,151 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { expect, openSidebarEntry, test, type OrkasTestApp } from './fixtures/orkas';
import type { Page } from '@playwright/test';
// Substitute only the OS boundary. Real IPC, task storage, device marker and UI remain intact.
async function isolatedOS(orkas: OrkasTestApp) {
await orkas.electronApp!.evaluate(({ app }) => {
const load = (process as any).mainModule.require.bind((process as any).mainModule);
const fs = load('node:fs'), path = load('node:path');
const root = app.getAppPath();
const startup = load(path.join(root, 'src/main/features/login_startup.ts'));
const users = load(path.join(root, 'src/main/features/users.ts'));
const auto = load(path.join(root, 'src/main/features/auto_tasks.ts'));
const file = path.join(process.env.ORKAS_WORKSPACE_ROOT!, 'fixture-startup.json');
const read = () => fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : { openAtLogin: false, status: 'not-registered' };
startup.setLoginStartup(startup.createLoginStartup({
platform: 'darwin', available: true, executable: 'test-only', get: read,
set: (value: any) => {
const old = read(); if (old.fail) throw new Error('controlled system refusal');
fs.writeFileSync(file, JSON.stringify({ openAtLogin: value.openAtLogin, status: value.openAtLogin ? old.approval ? 'requires-approval' : 'enabled' : 'not-registered' }));
},
getUser: users.getActiveUserId,
hasEnabledAutomations: async (uid: string) => (await auto.listTasks(uid)).some((task: any) => task.enabled === true),
}));
});
}
async function settings(page: Page) {
await page.locator('#settings-btn').click();
await page.locator('[data-settings-tab="general"]').click();
// Switching settings does not create an OS registration.
await page.evaluate(() => window.dispatchEvent(new Event('focus')));
}
const dialog = (page: Page) => page.locator('.ui-dialog-overlay').filter({ has: page.locator('[data-id="enable-startup"]') });
const device = (orkas: OrkasTestApp) => path.join(orkas.workspaceRoot, 'device.json');
async function createTask(orkas: OrkasTestApp, enabled: boolean) {
return orkas.invoke<{ task: { id: string } }>('autoTasks.create', { title: 'Startup guidance task', content: 'Prepare a report', enabled, schedule: { type: 'one_time', at: '2099-12-01T10:00:00.000Z' } });
}
test('startup setting starts off with the accepted copy and persists across direct quit', async ({ orkas }, info) => {
const page = orkas.page!;
expect((await orkas.invoke<any>('desktop.getStartup')).startup.available).toBe(false);
await settings(page);
await expect(page.locator('#settings-startup-toggle')).toBeDisabled();
await expect(page.locator('#settings-startup-status')).toBeHidden();
await page.screenshot({ path: info.outputPath('startup-settings-dev.png') });
await isolatedOS(orkas);
await orkas.invoke('config.setLanguage', { language: 'zh' }); await page.reload();
await settings(page);
await expect(page.locator('#settings-startup-title')).toHaveText('开机启动');
await expect(page.locator('#settings-startup-description')).toHaveText('电脑开机后,自动启动 Orkas。');
await expect(page.locator('#settings-startup-toggle')).not.toBeChecked();
await expect(page.locator('#settings-background-row')).toHaveCount(0);
await expect(page.locator('#settings-startup-toggle')).toBeEnabled();
await expect(page.locator('#settings-startup-status')).toBeHidden();
await expect(page.locator('#settings-startup-open-settings')).toBeHidden();
await page.screenshot({ path: info.outputPath('startup-settings-zh.png') });
await page.locator('#settings-startup-toggle').check();
await expect.poll(async () => (await orkas.invoke<any>('desktop.getStartup')).startup.enabled).toBe(true);
const reopened = await orkas.relaunch(); await isolatedOS(orkas); await settings(reopened);
await expect(reopened.locator('#settings-startup-toggle')).toBeChecked();
await reopened.locator('#settings-startup-toggle').uncheck();
await expect.poll(async () => (await orkas.invoke<any>('desktop.getStartup')).startup.enabled).toBe(false);
await expect(reopened.locator('#settings-background-row')).toHaveCount(0);
expect(existsSync(device(orkas)) ? JSON.parse(readFileSync(device(orkas), 'utf8')).desktop?.background_enabled : undefined).toBeUndefined();
});
test('tab entry prompts only for enabled automation; Not now persists once without enabling or showing a native window', async ({ orkas }, info) => {
const page = orkas.page!; await isolatedOS(orkas);
await orkas.invoke('config.setLanguage', { language: 'zh' }); await page.reload();
const native = await orkas.electronApp!.browserWindow(page);
await native.evaluate(win => { (win as any).__startupShows = 0; win.on('show', () => { (win as any).__startupShows++; }); });
await createTask(orkas, false); await openSidebarEntry(page, '#auto-btn');
await expect(page.locator('.auto-row')).toHaveCount(1); await expect(dialog(page)).toHaveCount(0);
expect(JSON.parse(readFileSync(device(orkas), 'utf8')).desktop?.startup_prompt_shown).not.toBe(true);
await page.locator('#new-chat-btn').click(); await createTask(orkas, true);
await openSidebarEntry(page, '#auto-btn'); await expect(dialog(page)).toBeVisible();
await expect(dialog(page).locator('.modal')).toHaveClass(/modal-standard/);
await expect(dialog(page).locator('.modal-title')).toHaveText('开启开机启动?');
await expect(dialog(page).locator('.ui-dialog-message')).toHaveText('你已开启自动化任务,建议开启开机启动,让任务按计划执行。可在「设置 → 通用」修改。');
await expect(dialog(page).locator('.modal-actions button')).toHaveText(['暂不', '开启']);
await expect(dialog(page).locator('[data-act="cancel"]')).toBeFocused();
await page.setViewportSize({ width: 860, height: 640 });
const buttons = await dialog(page).locator('.modal-actions button').evaluateAll(nodes => nodes.map(n => { const r=n.getBoundingClientRect(); return { x:r.x, y:r.y, right:r.right, bottom:r.bottom }; }));
expect(buttons[1].x).toBeGreaterThan(buttons[0].x); expect(Math.abs(buttons[0].y-buttons[1].y)).toBeLessThan(2);
expect(buttons.every(r => r.right <= 860 && r.bottom <= 640)).toBe(true);
await page.screenshot({ path: info.outputPath('startup-prompt-zh-narrow.png') });
await dialog(page).locator('[data-act="cancel"]').click();
await expect.poll(() => JSON.parse(readFileSync(device(orkas), 'utf8')).desktop?.startup_prompt_shown).toBe(true);
expect((await orkas.invoke<any>('desktop.getStartup')).startup.enabled).toBe(false);
expect(existsSync(path.join(orkas.workspaceRoot, 'fixture-startup.json'))).toBe(false);
expect(await native.evaluate(win => (win as any).__startupShows)).toBe(0);
const reopened = await orkas.relaunch(); await isolatedOS(orkas); await openSidebarEntry(reopened, '#auto-btn');
await expect(reopened.locator('.auto-row')).toHaveCount(2); await expect(dialog(reopened)).toHaveCount(0);
});
test('explicit consent reports system refusal in the same standard dialog and supports retry', async ({ orkas }, info) => {
const page = orkas.page!; await isolatedOS(orkas); await createTask(orkas, true);
const file = path.join(orkas.workspaceRoot, 'fixture-startup.json');
writeFileSync(file, JSON.stringify({ openAtLogin: false, status: 'not-registered', fail: true }));
await openSidebarEntry(page, '#auto-btn'); await expect(dialog(page)).toBeVisible();
await expect(dialog(page)).toBeVisible();
await page.screenshot({ path: info.outputPath('startup-prompt-en.png') });
await dialog(page).locator('[data-id="enable-startup"]').click();
await expect(dialog(page).locator('.ui-dialog-error')).toHaveText('Could not change startup settings. Please try again.');
expect((await orkas.invoke<any>('desktop.getStartup')).startup.enabled).toBe(false);
writeFileSync(file, JSON.stringify({ openAtLogin: false, status: 'not-registered' }));
await dialog(page).locator('[data-id="enable-startup"]').click(); await expect(dialog(page)).toHaveCount(0);
await settings(page); await expect(page.locator('#settings-startup-toggle')).toBeChecked();
await expect(page.locator('#settings-background-row')).toHaveCount(0);
});
test('pending OS approval and external disable remain authoritative and do not repeatedly offer guidance', async ({ orkas }, info) => {
const page = orkas.page!; await isolatedOS(orkas); await createTask(orkas, true); await settings(page);
const file = path.join(orkas.workspaceRoot, 'fixture-startup.json');
writeFileSync(file, JSON.stringify({ openAtLogin: false, status: 'not-registered', approval: true }));
// Pending system approval keeps the effective switch off after the click.
await page.locator('#settings-startup-toggle').click();
await expect(page.locator('#settings-startup-status')).toHaveText('Allow Orkas in your system startup settings.');
await expect(page.locator('#settings-startup-open-settings')).toBeVisible();
await expect(page.locator('#settings-startup-toggle')).not.toBeChecked();
await page.screenshot({ path: info.outputPath('startup-approval-en.png') });
await openSidebarEntry(page, '#auto-btn'); await expect(page.locator('.auto-row')).toHaveCount(1); await expect(dialog(page)).toHaveCount(0);
writeFileSync(file, JSON.stringify({ openAtLogin: true, status: 'enabled' }));
await settings(page); await expect(page.locator('#settings-startup-toggle')).toBeChecked();
writeFileSync(file, JSON.stringify({ openAtLogin: false, status: 'not-registered' }));
await page.evaluate(() => window.dispatchEvent(new Event('focus')));
await expect(page.locator('#settings-startup-toggle')).not.toBeChecked();
});
test('a failed settings write restores the OS switch state and supports explicit retry without adding obsolete background preferences', async ({ orkas }) => {
const page = orkas.page!; await isolatedOS(orkas); await settings(page);
const file = path.join(orkas.workspaceRoot, 'fixture-startup.json');
writeFileSync(file, JSON.stringify({ openAtLogin: false, status: 'not-registered', fail: true }));
await page.locator('#settings-startup-toggle').click();
await expect(page.getByText('Could not change startup settings. Please try again.', { exact: true })).toBeVisible();
await expect(page.locator('#settings-startup-toggle')).not.toBeChecked();
await expect(page.locator('#settings-startup-toggle')).toBeEnabled();
expect((await orkas.invoke<any>('desktop.getStartup')).startup.enabled).toBe(false);
writeFileSync(file, JSON.stringify({ openAtLogin: false, status: 'not-registered' }));
await page.locator('#settings-startup-toggle').check();
await expect.poll(async () => (await orkas.invoke<any>('desktop.getStartup')).startup.enabled).toBe(true);
await expect(page.locator('#settings-background-row')).toHaveCount(0);
});
test('already enabled startup suppresses guidance without consuming it; an external disable makes guidance eligible', async ({ orkas }) => {
const page = orkas.page!; await isolatedOS(orkas); await createTask(orkas, true); await settings(page);
await page.locator('#settings-startup-toggle').check();
await expect.poll(async () => (await orkas.invoke<any>('desktop.getStartup')).startup.enabled).toBe(true);
await openSidebarEntry(page, '#auto-btn'); await expect(page.locator('.auto-row')).toHaveCount(1);
await expect(dialog(page)).toHaveCount(0);
expect(JSON.parse(readFileSync(device(orkas), 'utf8')).desktop?.startup_prompt_shown).not.toBe(true);
await page.locator('#new-chat-btn').click();
writeFileSync(path.join(orkas.workspaceRoot, 'fixture-startup.json'), JSON.stringify({ openAtLogin: false, status: 'not-registered' }));
await openSidebarEntry(page, '#auto-btn'); await expect(dialog(page)).toBeVisible();
await dialog(page).locator('[data-act="cancel"]').click();
expect((await orkas.invoke<any>('desktop.getStartup')).startup.enabled).toBe(false);
});
@@ -1,4 +1,5 @@
import { expect, test } from './fixtures/orkas';
test.skip(process.platform !== 'linux', 'Linux retains native close confirmation because no supported tray is available.');
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env node
/** Explicit opt-in: register ONLY a unique disposable app, then remove its item.
* No windows, accounts, reboot/logout, production identity, or signing credentials.
* An approval-required result fails enable verification instead of passing it.
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { randomUUID } from 'node:crypto';
import { spawnSync } from 'node:child_process';
const require = createRequire(import.meta.url);
const pcRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
if (!['darwin', 'win32'].includes(process.platform)) throw new Error('A macOS or Windows desktop host is required');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'orkas-startup-native-'));
const identity = `org.orkas.startup-test.${randomUUID()}`;
const reportDir = path.resolve(pcRoot, '../tmp/startup-native', identity);
fs.mkdirSync(reportDir, { recursive: true });
const report = { identity, platform: process.platform, root, results: [], phases: [], checks: [], cleanup: false, passed: false };
let executable, prepared = false;
function command(bin, args, env = process.env) {
const run = spawnSync(bin, args, { env, encoding: 'utf8', timeout: 45_000, windowsHide: true });
report.results.push({ command: path.basename(bin), args, status: run.status, signal: run.signal,
stdout: run.stdout || '', stderr: run.stderr || '', error: run.error?.message });
if (run.error || run.status !== 0) throw new Error(`${path.basename(bin)} failed (${run.status}): ${run.error?.message || run.stderr}`);
return run.stdout;
}
function check(name, run) { run(); report.checks.push(name); }
function phase(name) {
const env = { ...process.env, ORKAS_STARTUP_NATIVE_PHASE: name };
// Windows Electron treats an empty value as Node mode too.
delete env.ELECTRON_RUN_AS_NODE;
const file = path.join(root, `${name}.json`);
fs.rmSync(file, { force: true });
let value;
try { command(executable, [], env); }
finally {
if (fs.existsSync(file)) {
value = JSON.parse(fs.readFileSync(file, 'utf8'));
report.phases.push(value); report[name] = value;
}
}
assert.ok(value, `Missing native ${name} receipt`);
assert.equal(value.packaged, true); assert.equal(value.windows, 0); assert.equal(value.error, undefined);
return value;
}
try {
const electron = require('electron');
let resources;
if (process.platform === 'darwin') {
const source = path.resolve(electron, '../../..');
const bundle = path.join(root, 'Startup Verification.app');
command('/usr/bin/ditto', [source, bundle]);
const plist = path.join(bundle, 'Contents/Info.plist');
for (const [key, value] of [['CFBundleIdentifier', identity], ['CFBundleName', identity], ['CFBundleDisplayName', 'Startup Verification'], ['CFBundleExecutable', 'StartupVerification']]) {
command('/usr/libexec/PlistBuddy', ['-c', `Set :${key} ${value}`, plist]);
}
command('/usr/libexec/PlistBuddy', ['-c', 'Add :LSUIElement bool true', plist]);
executable = path.join(bundle, 'Contents/MacOS/StartupVerification');
fs.renameSync(path.join(bundle, 'Contents/MacOS', path.basename(electron)), executable);
resources = path.join(bundle, 'Contents/Resources');
} else {
const target = path.join(root, 'Startup Verification application');
fs.cpSync(path.dirname(electron), target, { recursive: true });
executable = path.join(target, 'StartupVerification.exe');
fs.renameSync(path.join(target, path.basename(electron)), executable);
resources = path.join(target, 'resources');
}
for (const name of ['app', 'app.asar', 'default_app.asar']) fs.rmSync(path.join(resources, name), { recursive: true, force: true });
const appRoot = path.join(resources, 'app'); fs.mkdirSync(appRoot);
fs.copyFileSync(path.join(pcRoot, 'test/gui/startup-native-main.cjs'), path.join(appRoot, 'main.cjs'));
fs.writeFileSync(path.join(appRoot, 'package.json'), JSON.stringify({ name: identity, version: '1.0.0', main: 'main.cjs' }));
fs.writeFileSync(path.join(appRoot, 'fixture.json'), JSON.stringify({ root, pcRoot, identity }));
if (process.platform === 'darwin') command('/usr/bin/codesign', ['--force', '--deep', '--sign', '-', path.resolve(executable, '../../..')]);
prepared = true;
check('fresh packaged identity defaults off without consent', () => {
const initial = phase('read'); assert.equal(initial.state.enabled, false); assert.equal(initial.native.openAtLogin, false);
});
const enabled = phase('enable');
check('explicit enable is effectively enabled in the real OS', () => {
assert.equal(enabled.state.status, 'enabled', 'OS approval is pending or registration failed; enabling is NOT verified');
assert.equal(enabled.native.openAtLogin, true);
if (process.platform === 'darwin') assert.equal(enabled.native.status, 'enabled');
else {
assert.equal(enabled.native.executableWillLaunchAtLogin, true);
const matching = enabled.native.launchItems.filter(item => item.name === identity);
assert.equal(matching.length, 1); assert.equal(matching[0].enabled, true);
assert.equal(matching[0].path.toLowerCase(), executable.toLowerCase());
// Chromium's launchItems.args omits switches. Read the exact test-owned
// registry command independently to prove the startup argument survives.
const script = `$ErrorActionPreference='Stop'; $key=[Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Software\\Microsoft\\Windows\\CurrentVersion\\Run'); try { [Console]::Write($key.GetValue('${identity}')) } finally { $key.Dispose() }`;
const registry = command('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')]);
assert.equal(registry.trim(), `"${executable}" --orkas-startup`);
}
});
check('enabled registration survives a fresh application process', () => assert.equal(phase('read').state.enabled, true));
check('startup registration never writes a local enabled flag or changes background preferences', () => assert.equal(fs.existsSync(path.join(root, 'data/device.json')), false));
} catch (error) { report.failure = error.stack || String(error); }
finally {
// Run separately even when enable/read assertions failed. Keep the bundle if
// cleanup cannot be verified, so an orphan never points at a deleted executable.
if (prepared) {
try {
assert.equal(phase('disable').state.enabled, false);
const disabled = phase('read');
assert.equal(disabled.native.openAtLogin, false);
if (process.platform === 'darwin') assert.equal(disabled.native.status, 'not-registered');
else {
// Read only this disposable identity; never print other startup items.
const script = `$ErrorActionPreference='Stop'; $key=[Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Software\\Microsoft\\Windows\\CurrentVersion\\Run'); try { if ($null -eq $key) { 'absent' } elseif ($key.GetValueNames() -contains '${identity}') { 'present' } else { 'absent' } } finally { if ($null -ne $key) { $key.Dispose() } }`;
assert.equal(command('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')]).trim(), 'absent');
}
report.cleanup = true;
report.checks.push('disable removes registration and remains off in a fresh process');
} catch (error) { report.cleanupFailure = error.stack || String(error); }
}
report.passed = !report.failure && report.cleanup;
const unexpected = report.results.filter(result => {
const expectedSignature = result.command === 'codesign' && result.stderr.trim() === `${result.args.at(-1)}: replacing existing signature`;
const expectedRegistry = ['reg.exe', 'powershell.exe'].includes(result.command) && result.status === 0;
return (result.stderr.trim() && !expectedSignature) || (result.stdout.trim() && !expectedRegistry);
});
report.logAnalysis = { commands: report.results.length, unexpected: unexpected.length,
note: 'codesign replacement notices and the test-owned registry query are expected; all Electron output is reviewed as unexpected' };
if (unexpected.length) { report.passed = false; report.logFailure = 'Unexpected native command output; inspect retained receipts'; }
fs.writeFileSync(path.join(reportDir, 'report.json'), JSON.stringify(report, null, 2));
if (report.cleanup) fs.rmSync(root, { recursive: true, force: true });
console.log(`[startup-native] ${report.passed ? 'PASS' : 'FAIL'} checks=${report.checks.length} cleanup=${report.cleanup} report=${path.join(reportDir, 'report.json')}`);
if (report.failure) console.error(report.failure);
if (report.cleanupFailure) console.error(report.cleanupFailure);
if (report.logFailure) console.error(report.logFailure);
}
process.exitCode = report.passed ? 0 : 1;
+37
View File
@@ -0,0 +1,37 @@
// Test-owned packaged identity only. Never load the application bootstrap/account.
const { app, BrowserWindow } = require('electron');
const fs = require('node:fs');
const path = require('node:path');
const config = JSON.parse(fs.readFileSync(path.join(__dirname, 'fixture.json'), 'utf8'));
process.env.ORKAS_WORKSPACE_ROOT = path.join(config.root, 'data');
app.setPath('userData', path.join(config.root, 'electron-profile'));
app.setName(config.identity);
if (process.platform === 'win32') app.setAppUserModelId(config.identity);
if (process.platform === 'darwin') app.setActivationPolicy('prohibited');
require(path.join(config.pcRoot, 'node_modules/tsx/dist/cjs/index.cjs'));
const { createLoginStartup } = require(path.join(config.pcRoot, 'src/main/features/login_startup.ts'));
let windows = 0;
app.on('browser-window-created', () => { windows++; });
app.whenReady().then(() => {
const phase = process.env.ORKAS_STARTUP_NATIVE_PHASE || 'read';
const result = { phase, packaged: app.isPackaged, executable: process.execPath };
try {
if (!app.isPackaged || !config.identity.startsWith('org.orkas.startup-test.')) throw new Error('Unsafe test identity');
const service = createLoginStartup({
platform: process.platform, available: app.isPackaged, executable: process.execPath,
get: options => app.getLoginItemSettings(options),
set: settings => app.setLoginItemSettings(settings),
getUser: () => 'test-only', hasEnabledAutomations: async () => false,
});
result.before = service.read();
result.state = phase === 'enable' ? service.setEnabled(true) : phase === 'disable' ? service.setEnabled(false) : service.read();
} catch (error) { result.error = error.code || error.message; }
// Keep the independent OS observation even when the service rejects a write.
try {
result.native = app.getLoginItemSettings(process.platform === 'win32'
? { path: `"${process.execPath}"`, args: ['--orkas-startup'] } : undefined);
} catch (error) { result.nativeError = error.code || error.message; }
result.windows = windows + BrowserWindow.getAllWindows().length;
fs.writeFileSync(path.join(config.root, `${phase}.json`), JSON.stringify(result, null, 2));
app.exit(result.error ? 1 : 0);
}).catch(() => app.exit(1));
@@ -0,0 +1,109 @@
import { EventEmitter } from 'node:events';
import { describe, expect, it, vi } from 'vitest';
import { createDesktopWindowController } from '../../../src/main/features/desktop_windows';
function fixture(canHide = true) {
const send = vi.fn(), hide = vi.fn(), show = vi.fn(), quit = vi.fn(), write = vi.fn(), read = vi.fn(() => ({ enabled: false, choiceMade: false }));
const win: any = Object.assign(new EventEmitter(), { isDestroyed: () => false, isFocusable: () => false, hide, show, focus: vi.fn(),
webContents: Object.assign(new EventEmitter(), { isDestroyed: () => false, send }) });
let quitting = false, prepared = false;
const owner = createDesktopWindowController({ canHide: () => canHide, isShutdownComplete: () => prepared,
read, write, risk: vi.fn(async () => ({ running: true, automation: true, driver: true })), getUser: () => 'fixture', quit, failed: vi.fn() } as any);
owner.install(win, () => quitting);
const close = () => { const event = { preventDefault: vi.fn() }; win.emit('close', event); return event; };
return { owner, win, close, send, hide, show, quit, read, write, prepare: () => { prepared = true; }, exit: () => { quitting = true; } };
}
describe('closing a window always keeps background work alive', () => {
it('first and repeated closes hide immediately without reading preferences, inspecting work, prompting or quitting', () => {
const h = fixture();
for (let i = 0; i < 2; i++) expect(h.close().preventDefault).toHaveBeenCalledOnce();
expect(h.hide).toHaveBeenCalledTimes(2);
expect(h.read).not.toHaveBeenCalled(); expect(h.write).not.toHaveBeenCalled();
expect(h.send).not.toHaveBeenCalled(); expect(h.quit).not.toHaveBeenCalled(); expect(h.show).not.toHaveBeenCalled();
});
it.each(['prepare', 'exit'] as const)('allows windows to be destroyed during %s without hiding again', phase => {
const h = fixture(); h[phase]();
expect(h.close().preventDefault).not.toHaveBeenCalled();
expect(h.hide).not.toHaveBeenCalled(); expect(h.send).not.toHaveBeenCalled();
});
it('keeps an existing Windows window recoverable if its tray failed without presenting a warning', () => {
const h = fixture(false); expect(h.close().preventDefault).toHaveBeenCalledOnce();
expect(h.hide).not.toHaveBeenCalled(); expect(h.quit).not.toHaveBeenCalled(); expect(h.send).not.toHaveBeenCalled();
});
it('restricts startup settings to the live main window and releases ownership on destruction', () => {
const h = fixture(); expect(h.owner.owns(h.win.webContents)).toBe(true); expect(h.owner.owns({} as any)).toBe(false);
h.win.emit('closed'); expect(h.owner.owns(h.win.webContents)).toBe(false);
});
it('keeps Windows recovery available across tray loss and restoration without starting shutdown', () => {
let trayAvailable = false;
const h = fixture();
const controller = createDesktopWindowController({ canHide: () => trayAvailable, isShutdownComplete: () => false });
h.win.removeAllListeners('close'); controller.install(h.win);
for (const available of [false, true, false, true]) {
trayAvailable = available; h.hide.mockClear();
expect(h.close().preventDefault).toHaveBeenCalledOnce();
expect(h.hide).toHaveBeenCalledTimes(available ? 1 : 0);
}
expect(h.quit).not.toHaveBeenCalled(); expect(h.send).not.toHaveBeenCalled();
});
});
// Hidden E2E cannot surface native windows; exercise the shipped restoration
// function with a focusable window here to cover the normal user presentation.
it.each([
{ minimized: true, visible: false },
{ minimized: false, visible: false },
{ minimized: false, visible: true },
])('the shipped Open action preserves the existing window for %j', async ({ minimized, visible }) => {
const fs = await import('node:fs'), path = await import('node:path'), ts = await import('typescript'), vm = await import('node:vm');
const source = fs.readFileSync(path.resolve('src/main/index.ts'), 'utf8');
const tree = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, true);
const fn = tree.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'showMainWindow');
expect(fn).toBeDefined();
const win = { isDestroyed: () => false, isFocusable: () => true, isMinimized: () => minimized, isVisible: () => visible,
restore: vi.fn(), show: vi.fn(), focus: vi.fn() };
const createWindow = vi.fn(), target = { exports: undefined as any };
vm.runInNewContext(ts.transpileModule(`${fn!.getText(tree)};module.exports=showMainWindow;`, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText,
{ module: target, mainWindows: new Set([win]), createWindow });
expect(target.exports()).toBe(win); expect(createWindow).not.toHaveBeenCalled();
expect(win.restore).toHaveBeenCalledTimes(minimized ? 1 : 0); expect(win.show).toHaveBeenCalledTimes(visible ? 0 : 1); expect(win.focus).toHaveBeenCalledOnce();
win.isFocusable = () => false; win.restore.mockClear(); win.show.mockClear(); win.focus.mockClear();
expect(target.exports()).toBe(win); expect(win.restore).not.toHaveBeenCalled(); expect(win.show).not.toHaveBeenCalled(); expect(win.focus).not.toHaveBeenCalled();
// A stale destroyed entry must never prevent recovery through a new window.
const replacement = { ...win, isFocusable: () => true, restore: vi.fn(), show: vi.fn(), focus: vi.fn() };
win.isDestroyed = () => true; createWindow.mockReturnValue(replacement);
expect(target.exports()).toBe(replacement); expect(createWindow).toHaveBeenCalledOnce();
expect(replacement.focus).toHaveBeenCalledOnce();
});
// Linux has no supported tray: the existing native-close owner must retain
// its close/active-work confirmation while desktop IPC ownership still works.
it('leaves Linux native closes to the existing owner without hiding or losing IPC identity', () => {
const h = fixture(); h.win.removeAllListeners('close');
const owner = createDesktopWindowController({ canHide: () => false, nativeClose: () => true, isShutdownComplete: () => false });
owner.install(h.win);
expect(owner.owns(h.win.webContents)).toBe(true);
expect(h.close().preventDefault).not.toHaveBeenCalled();
expect(h.hide).not.toHaveBeenCalled(); expect(h.quit).not.toHaveBeenCalled();
});
it.each(['linux', 'darwin', 'win32'] as const)('the shipped %s wiring preserves its actual close owner', async platform => {
const fs = await import('node:fs'), ts = await import('typescript'), vm = await import('node:vm');
const source = fs.readFileSync('src/main/index.ts', 'utf8');
const tree = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, true);
let initializer: any;
function visit(node: any) {
if (ts.isVariableDeclaration(node) && node.name.getText(tree) === 'desktopWindows') initializer = node.initializer;
ts.forEachChild(node, visit);
}
visit(tree); expect(initializer).toBeDefined();
const target = { exports: undefined as any };
vm.runInNewContext(ts.transpileModule('module.exports = ' + initializer.getText(tree), { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText,
{ module: target, createDesktopWindowController, shutdown: { complete: false }, process: { platform }, appTray: null });
const h = fixture(); h.win.removeAllListeners('close'); target.exports.install(h.win);
expect(target.exports.owns(h.win.webContents)).toBe(true);
const event = h.close();
expect(event.preventDefault).toHaveBeenCalledTimes(platform === 'linux' ? 0 : 1);
expect(h.hide).toHaveBeenCalledTimes(platform === 'darwin' ? 1 : 0);
});
+54
View File
@@ -0,0 +1,54 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as vm from 'node:vm';
import * as ts from 'typescript';
import { describe, expect, it, vi } from 'vitest';
/** Execute the shipped IPC handlers while controlling device I/O and sender ownership. */
function fixture() {
const source = fs.readFileSync(path.resolve('src/main/ipc/index.ts'), 'utf8');
const tree = ts.createSourceFile('ipc.ts', source, ts.ScriptTarget.Latest, true);
const properties: string[] = [];
const visit = (node: ts.Node) => {
if (ts.isPropertyAssignment(node) && ts.isStringLiteral(node.name) && node.name.text.startsWith('desktop.')) properties.push(node.getText(tree));
ts.forEachChild(node, visit);
};
visit(tree);
const owner = {};
let prefs = { enabled: true, choiceMade: false };
const write = vi.fn(next => { prefs = next; return next; });
const cancel = vi.fn();
const resolve = vi.fn(async () => ({ ok: true }));
const startup = { read: vi.fn(() => ({ enabled: false, available: true, status: 'disabled' })),
setEnabled: vi.fn(enabled => ({ enabled })), claimPrompt: vi.fn(async () => 'claim'),
markPromptShown: vi.fn(() => true), releasePrompt: vi.fn() };
const sandbox: any = {
module: { exports: {} },
readDesktopPreferences: () => prefs, writeDesktopPreferences: write,
getDesktopWindowController: () => ({ owns: sender => sender === owner, cancel, resolve }),
getLoginStartup: () => startup, startupOperation: async run => run(),
};
vm.runInNewContext(ts.transpileModule(`module.exports = {${properties.join(',')}};`, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, sandbox);
return { handlers: sandbox.module.exports, owner, write, cancel, resolve, startup, prefs: () => prefs };
}
describe('device-local settings IPC authority', () => {
it('rejects startup control from auxiliary windows and never coerces consent values', async () => {
const h = fixture();
for (const name of ['getStartup', 'setStartup', 'claimStartupPrompt', 'markStartupPromptShown', 'releaseStartupPrompt', 'openStartupSettings']) {
expect(await h.handlers[`desktop.${name}`]({ enabled: true }, { sender: {} })).toMatchObject({ ok: false, code: 'E_IPC_SENDER' });
}
for (const enabled of ['true', 1, null, {}, undefined]) {
expect(await h.handlers['desktop.setStartup']({ enabled }, { sender: h.owner })).toMatchObject({ ok: false, code: 'E_STARTUP_INPUT' });
}
expect(h.startup.setEnabled).not.toHaveBeenCalled();
await h.handlers['desktop.setStartup']({ enabled: true, userId: 'forged' }, { sender: h.owner, userId: 'real' });
expect(h.startup.setEnabled).toHaveBeenCalledWith(true);
expect(h.write).not.toHaveBeenCalled();
await h.handlers['desktop.claimStartupPrompt']({ userId: 'forged' }, { sender: { ...h.owner }, userId: 'real' });
expect(h.startup.claimPrompt).not.toHaveBeenCalled();
});
it('removes obsolete background preference and close-decision channels entirely', () => {
const h = fixture();
for (const name of ['getPreferences', 'setBackground', 'resolveClose']) expect(h.handlers[`desktop.${name}`]).toBeUndefined();
});
});
@@ -0,0 +1,81 @@
import { EventEmitter } from 'node:events';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as vm from 'node:vm';
import * as ts from 'typescript';
import { describe, expect, it, vi } from 'vitest';
import { createDesktopWindowController } from '../../../src/main/features/desktop_windows';
describe('prepared shutdown bypasses background close without changing preferences', () => {
it.each(['darwin', 'win32'])('closes for the installer only after shutdown preparation completes on %s', async platform => {
const source = fs.readFileSync(path.resolve('src/main/index.ts'), 'utf8');
const tree = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, true);
let composition = '';
function visit(node: ts.Node) {
if (ts.isCallExpression(node) && node.expression.getText(tree) === 'createDesktopWindowController') composition = node.getText(tree);
ts.forEachChild(node, visit);
}
visit(tree); expect(composition).not.toBe('');
const shutdown = { complete: false };
const writes = vi.fn(), risk = vi.fn(), hide = vi.fn(), quit = vi.fn();
const target = { exports: undefined as ReturnType<typeof createDesktopWindowController> | undefined };
vm.runInNewContext(ts.transpileModule(`module.exports = ${composition}`, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, {
module: target, createDesktopWindowController, shutdown, readDesktopPreferences: () => { throw new Error('Retired preference read'); },
writeDesktopPreferences: writes, readDesktopWorkRisk: risk, users: { getActiveUserId: () => 'fixture' },
process: { platform }, appTray: {}, app: { quit }, setImmediate: (run: () => void) => run(),
});
const owner = target.exports!;
const win = Object.assign(new EventEmitter(), { isDestroyed: () => false, isFocusable: () => false, hide,
webContents: Object.assign(new EventEmitter(), { isDestroyed: () => false, send: vi.fn() }) });
owner.install(win as any, () => false);
const duringPreparation = { preventDefault: vi.fn() }; win.emit('close', duringPreparation);
expect(duringPreparation.preventDefault).toHaveBeenCalledOnce(); hide.mockClear();
shutdown.complete = true;
const installerClose = { preventDefault: vi.fn() }; win.emit('close', installerClose);
expect(installerClose.preventDefault, 'native shutdown closes windows before the ordinary before-quit event').not.toHaveBeenCalled();
expect(hide).not.toHaveBeenCalled(); expect(writes).not.toHaveBeenCalled(); expect(risk).not.toHaveBeenCalled();
});
});
function quitComposition() {
const source = fs.readFileSync(path.resolve('src/main/index.ts'), 'utf8');
const tree = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, true);
let body = '';
function visit(node: ts.Node) {
if (ts.isCallExpression(node) && node.expression.getText(tree) === 'app.on'
&& node.arguments[0]?.getText(tree) === "'before-quit'" && ts.isArrowFunction(node.arguments[1])
&& node.arguments[1].getText(tree).includes('flushForShutdown')) body = node.arguments[1].getText(tree);
ts.forEachChild(node, visit);
}
visit(tree); expect(body).not.toBe('');
const shutdown = { complete: false }, app = { quit: vi.fn() }, reveal = vi.fn(), error = vi.fn();
const flush = vi.fn(async () => { shutdown.complete = true; });
const sandbox: any = { module: { exports: undefined }, shutdown, app, isQuitting: false,
flushForShutdown: flush, showMainWindow: reveal,
log: { error }, logErrorSummary: () => ({ code: 'fixture-failure' }) };
vm.runInNewContext(ts.transpileModule(`module.exports = ${body}`, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, sandbox);
return { request: () => { const event = { preventDefault: vi.fn() }; return { event, completion: sandbox.module.exports(event) }; },
shutdown, app, flush, reveal, error, sandbox };
}
describe('native quit prepares complete shutdown directly', () => {
it('coalesces repeated quit requests, cleans up once and then allows native window destruction', async () => {
const h = quitComposition();
let release!: () => void;
h.flush.mockImplementationOnce(() => new Promise<void>(resolve => { release = () => { h.shutdown.complete = true; resolve(); }; }));
const first = h.request(); expect(first.event.preventDefault).toHaveBeenCalledOnce();
const duplicate = h.request(); await duplicate.completion;
expect(h.flush).toHaveBeenCalledOnce(); expect(h.app.quit).not.toHaveBeenCalled();
release(); await first.completion;
expect(h.app.quit).toHaveBeenCalledOnce();
const terminal = h.request(); await terminal.completion;
expect(terminal.event.preventDefault).not.toHaveBeenCalled(); expect(h.flush).toHaveBeenCalledOnce();
});
it('restores a recoverable window after cleanup fails and permits an explicit retry', async () => {
const h = quitComposition(); h.flush.mockRejectedValueOnce(new Error('controlled archive failure'));
await h.request().completion;
expect(h.app.quit).not.toHaveBeenCalled(); expect(h.reveal).toHaveBeenCalledOnce(); expect(h.error).toHaveBeenCalledOnce();
expect(h.sandbox.isQuitting).toBe(false);
await h.request().completion; expect(h.flush).toHaveBeenCalledTimes(2); expect(h.app.quit).toHaveBeenCalledOnce();
});
});
+160
View File
@@ -0,0 +1,160 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const state = vi.hoisted(() => ({ file: '' }));
vi.mock('../../../src/main/paths', () => ({ get DEVICE_FILE() { return state.file; } }));
import { createLoginStartup, shouldHideStartupWindow, STARTUP_ARGUMENT } from '../../../src/main/features/login_startup';
let root: string;
beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'login-startup-')); state.file = path.join(root, 'device.json'); });
afterEach(() => fs.rmSync(root, { force: true, recursive: true }));
function fixture(platform: 'darwin' | 'win32' = 'darwin', available = true) {
let native: any = platform === 'darwin' ? { openAtLogin: false, status: 'not-registered' } : { openAtLogin: false, executableWillLaunchAtLogin: false };
const write = vi.fn((settings: any) => { native = platform === 'darwin'
? { openAtLogin: settings.openAtLogin, status: settings.openAtLogin ? 'enabled' : 'not-registered' }
: { openAtLogin: settings.openAtLogin, executableWillLaunchAtLogin: settings.openAtLogin }; });
let uid = 'a', automations = false;
const deps = { platform, available, executable: '/Applications/Orkas.app/Orkas', get: vi.fn(() => native), set: write,
getUser: () => uid, hasEnabledAutomations: vi.fn(async () => automations) };
const service = createLoginStartup(deps);
return { service, deps, write, native: (value: any) => { native = value; }, user: (value: string) => { uid = value; }, enabledTasks: () => { automations = true; } };
}
describe('explicit device-local startup consent', () => {
it.each(['darwin', 'win32'] as const)('defaults off without registering anything, enables/disables through the %s system source', platform => {
const h = fixture(platform);
expect(h.service.read()).toMatchObject({ enabled: false, status: 'disabled', available: true });
expect(h.write).not.toHaveBeenCalled(); expect(fs.existsSync(state.file)).toBe(false);
expect(h.service.setEnabled(true)).toMatchObject({ enabled: true, status: 'enabled' });
expect(h.service.setEnabled(false)).toMatchObject({ enabled: false, status: 'disabled' });
expect(h.write.mock.calls[0][0]).toMatchObject(platform === 'win32' ? { args: [STARTUP_ARGUMENT], openAtLogin: true } : { openAtLogin: true });
});
it('does not infer permission from local preferences or re-enable an OS-disabled item', () => {
fs.writeFileSync(state.file, JSON.stringify({ device_id: 'identity', desktop: { background_enabled: false, startup_enabled: true, future: 4 } }));
const h = fixture('win32');
h.native({ openAtLogin: true, executableWillLaunchAtLogin: false });
expect(h.service.read()).toMatchObject({ enabled: false, status: 'disabled' });
h.service.setEnabled(true);
expect(JSON.parse(fs.readFileSync(state.file, 'utf8')).desktop).toMatchObject({ background_enabled: false, future: 4 });
});
it('recognizes an approved Windows installation with spaces without changing the registered executable', () => {
const h = fixture('win32');
h.deps.executable = 'C:\\Program Files\\Orkas\\Orkas.exe';
// Electron 42 parses the lookup path as a command line. A bare spaced
// path reports registration but omits the effective launch item.
h.deps.get.mockImplementation((options?: any) => ({ openAtLogin: true,
executableWillLaunchAtLogin: options?.path === '"C:\\Program Files\\Orkas\\Orkas.exe"' }));
expect(h.service.read()).toMatchObject({ enabled: true, status: 'enabled' });
expect(h.service.setEnabled(true).enabled).toBe(true);
expect(h.write).toHaveBeenCalledExactlyOnceWith({ path: 'C:\\Program Files\\Orkas\\Orkas.exe',
args: ['--orkas-startup'], openAtLogin: true, enabled: true });
});
it('reports pending system approval, refused writes and read failure without claiming enabled', () => {
const h = fixture(); h.write.mockImplementation(() => { h.native({ openAtLogin: true, status: 'requires-approval' }); });
expect(h.service.setEnabled(true)).toMatchObject({ enabled: false, status: 'approval' });
h.write.mockImplementation(() => { h.native({ openAtLogin: false, status: 'not-registered' }); });
expect(() => h.service.setEnabled(true)).toThrow('E_STARTUP_SAVE');
h.deps.get.mockImplementation(() => { throw new Error('private OS detail'); });
expect(() => h.service.read()).toThrow('E_STARTUP_READ');
});
it('never touches machine startup registration in development or isolated runtimes', () => {
const h = fixture('darwin', false);
expect(h.service.read()).toMatchObject({ enabled: false, available: false });
expect(() => h.service.setEnabled(true)).toThrow('E_STARTUP_UNAVAILABLE');
expect(h.deps.get).not.toHaveBeenCalled(); expect(h.write).not.toHaveBeenCalled();
});
it.each(['not-registered', 'not-found', 'requires-approval', 'unknown'])('does not claim macOS enable success from openAtLogin alone when status is %s', status => {
const h = fixture(); h.native({ openAtLogin: true, status });
expect(h.service.read()).toEqual({ enabled: false, available: true, status: status === 'requires-approval' ? 'approval' : 'disabled' });
expect(h.write).not.toHaveBeenCalled();
});
it.each([[false, true], [true, false], [false, false]])('requires both matching Windows registration (%s) and effective system approval (%s)', (openAtLogin, executableWillLaunchAtLogin) => {
const h = fixture('win32'); h.native({ openAtLogin, executableWillLaunchAtLogin });
expect(h.service.read().enabled).toBe(false); expect(h.write).not.toHaveBeenCalled();
});
it('rejects failed disable and post-write readback failure, retains the OS truth, and supports retry', () => {
const h = fixture('win32'); h.service.setEnabled(true);
h.write.mockImplementationOnce(() => {});
expect(() => h.service.setEnabled(false)).toThrow('E_STARTUP_SAVE');
expect(h.service.read().enabled).toBe(true);
h.deps.get.mockImplementationOnce(() => { throw new Error('readback unavailable'); });
expect(() => h.service.setEnabled(false)).toThrow('E_STARTUP_SAVE');
expect(h.service.read().enabled).toBe(false);
expect(h.service.setEnabled(true).enabled).toBe(true);
expect(fs.existsSync(state.file)).toBe(false);
});
});
describe('one-time guidance at the automation tab', () => {
it('coalesces concurrent tab entries and recovers a crashed renderer lease without consuming guidance', async () => {
vi.useFakeTimers();
try {
const h = fixture(); h.enabledTasks();
const ids = await Promise.all([h.service.claimPrompt(1, 'a'), h.service.claimPrompt(2, 'a')]);
expect(ids.filter(Boolean)).toHaveLength(1);
const owner = ids[0] ? 1 : 2, id = ids.find(Boolean);
await vi.advanceTimersByTimeAsync(30_001);
expect(h.service.markPromptShown(owner, 'a', id)).toBe(false);
expect(fs.existsSync(state.file)).toBe(false);
const retry = await h.service.claimPrompt(3, 'a');
expect(retry).toEqual(expect.any(String));
expect(h.service.markPromptShown(3, 'a', retry)).toBe(true);
} finally { vi.useRealTimers(); }
});
it('bounds an unavailable task lookup and permits a later retry without granting startup consent', async () => {
vi.useFakeTimers();
try {
const h = fixture(); h.enabledTasks();
h.deps.hasEnabledAutomations.mockImplementationOnce(() => new Promise(() => {}));
const pending = expect(h.service.claimPrompt(1, 'a')).rejects.toThrow('E_STARTUP_READ');
await vi.advanceTimersByTimeAsync(5001); await pending;
expect(fs.existsSync(state.file)).toBe(false); expect(h.write).not.toHaveBeenCalled();
expect(await h.service.claimPrompt(1, 'a')).toEqual(expect.any(String));
} finally { vi.useRealTimers(); }
});
it('requires enabled tasks, an off switch and a never-shown marker; only rendered acknowledgement consumes it', async () => {
const h = fixture(); expect(await h.service.claimPrompt(1, 'a')).toBeNull();
h.enabledTasks(); const id = await h.service.claimPrompt(1, 'a'); expect(id).toEqual(expect.any(String));
expect(await h.service.claimPrompt(2, 'a')).toBeNull();
expect(h.service.markPromptShown(2, 'a', id)).toBe(false);
h.service.releasePrompt(1); expect(fs.existsSync(state.file)).toBe(false);
const next = await h.service.claimPrompt(2, 'a');
expect(h.service.markPromptShown(2, 'a', next)).toBe(true);
const reopened = fixture(); reopened.enabledTasks();
expect(await reopened.service.claimPrompt(1, 'a')).toBeNull();
expect(h.write).not.toHaveBeenCalled();
});
it('does not prompt for already enabled or pending startup and does not consume a marker', async () => {
const h = fixture(); h.enabledTasks(); h.service.setEnabled(true);
expect(await h.service.claimPrompt(1, 'a')).toBeNull();
h.native({ openAtLogin: true, status: 'requires-approval' });
expect(await h.service.claimPrompt(1, 'a')).toBeNull(); expect(fs.existsSync(state.file)).toBe(false);
});
it('invalidates a delayed account result and lets the new account try later', async () => {
const h = fixture(); let finish!: (enabled: boolean) => void;
h.deps.hasEnabledAutomations.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
const pending = h.service.claimPrompt(1, 'a'); h.user('b'); finish(true);
expect(await pending).toBeNull(); h.enabledTasks();
const id = await h.service.claimPrompt(2, 'b'); expect(id).toEqual(expect.any(String));
expect(h.service.markPromptShown(2, 'a', id)).toBe(false);
});
it('preserves identity and close preferences, and does not consume corrupt storage or failed task reads', async () => {
const h = fixture(); h.enabledTasks();
h.deps.hasEnabledAutomations.mockRejectedValueOnce(new Error('unavailable'));
await expect(h.service.claimPrompt(1, 'a')).rejects.toThrow('E_STARTUP_READ');
fs.writeFileSync(state.file, JSON.stringify({ device_id: 'stable', future: 7, desktop: { background_enabled: false, close_choice_made: true } }));
const id = await h.service.claimPrompt(1, 'a'); fs.writeFileSync(state.file, '{bad');
expect(() => h.service.markPromptShown(1, 'a', id)).toThrow('E_STARTUP_SAVE');
expect(fs.readFileSync(state.file, 'utf8')).toBe('{bad');
fs.writeFileSync(state.file, JSON.stringify({ device_id: 'stable', future: 7, desktop: { background_enabled: false, close_choice_made: true } }));
expect(h.service.markPromptShown(1, 'a', id)).toBe(true);
expect(JSON.parse(fs.readFileSync(state.file, 'utf8'))).toMatchObject({ device_id: 'stable', future: 7, desktop: { background_enabled: false, close_choice_made: true, startup_prompt_shown: true } });
});
});
describe('startup presentation is hidden for system startup and visible for manual launch', () => {
it.each([
['darwin', [], true, true, true], ['darwin', [], false, true, false],
['win32', [STARTUP_ARGUMENT], false, true, true], ['win32', [STARTUP_ARGUMENT], false, false, true],
['win32', [], false, true, false],
] as const)('selects the window presentation for %s %j', (platform, argv, wasOpenedAtLogin, background, expected) => {
expect(shouldHideStartupWindow({ platform, argv: [...argv], wasOpenedAtLogin })).toBe(expected);
});
});
@@ -0,0 +1,93 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as vm from 'node:vm';
import * as ts from 'typescript';
import { describe, expect, it, vi } from 'vitest';
import { createLoginStartup, shouldHideStartupWindow } from '../../../src/main/features/login_startup';
/** Execute the shipped boot composition, rather than duplicating its adapter or gates. */
function boot(options: { platform?: string; packaged?: boolean; smoke?: boolean; e2e?: boolean;
background?: boolean; atLogin?: boolean; argv?: string[]; tray?: boolean; failRead?: boolean } = {}) {
const source = fs.readFileSync(path.resolve('src/main/index.ts'), 'utf8');
const tree = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, true);
let block = '';
function visit(node: ts.Node) {
if (ts.isBlock(node)) {
const start = node.statements.findIndex(statement => ts.isVariableStatement(statement)
&& statement.declarationList.declarations.some(declaration => declaration.name.getText(tree) === 'startupAvailable'));
if (start >= 0) {
const end = node.statements.findIndex((statement, index) => index > start && ts.isIfStatement(statement)
&& statement.expression.getText(tree) === "process.platform === 'darwin' && appTray");
if (end < 0) throw new Error('Startup composition boundary missing');
block = node.statements.slice(start, end).filter(statement => {
// Independent public lifecycle/callback wiring is verified by its own suites.
const text = statement.getText(tree);
return !text.includes('openLifecycleTracking') && !text.includes("'will-quit'") && !text.includes('consumeColdLaunchConnectorCallback');
}).map(statement => statement.getText(tree)).join('\n');
}
}
ts.forEachChild(node, visit);
}
visit(tree); expect(block).not.toBe('');
let native = { openAtLogin: false, executableWillLaunchAtLogin: false, status: 'not-registered', wasOpenedAtLogin: options.atLogin ?? false };
const get = vi.fn(() => { if (options.failRead) throw new Error('OS read failed'); return native; });
const set = vi.fn(settings => { native = { ...native, openAtLogin: settings.openAtLogin,
executableWillLaunchAtLogin: settings.enabled ?? settings.openAtLogin, status: settings.openAtLogin ? 'enabled' : 'not-registered' }; });
const install = vi.fn(); const createWindow = vi.fn(); const showMainWindow = vi.fn(); const warn = vi.fn();
const tasks = vi.fn(async (_uid: string) => [{ enabled: false }, { enabled: true }]);
const background = vi.fn(() => ({ enabled: options.background ?? true }));
const executable = 'C:\\Program Files\\Orkas\\Orkas.exe';
const sandbox = { app: { isPackaged: options.packaged ?? true, getLoginItemSettings: get, setLoginItemSettings: set },
process: { platform: options.platform ?? 'win32', execPath: executable, argv: options.argv ?? [] },
IS_PACKAGED_LAUNCH_SMOKE: options.smoke ?? false, E2E_HIDE_WINDOW: options.e2e ?? false,
createLoginStartup, shouldHideStartupWindow, setLoginStartup: install,
users: { getActiveUserId: () => 'actual-account' }, autoTasks: { listTasks: tasks },
readDesktopPreferences: background, createWindow, createAppTray: vi.fn(), appTray: options.tray ?? true,
showMainWindow, log: { warn } };
vm.runInNewContext(ts.transpileModule(block, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, sandbox);
return { service: install.mock.calls[0][0] as ReturnType<typeof createLoginStartup>, get, set, createWindow,
showMainWindow, warn, executable, background, tasks };
}
describe('shipped startup boot and native adapter composition', () => {
it.each([{ packaged: false }, { smoke: true }, { e2e: true }])('never accesses or registers host startup for an isolated runtime %j', options => {
const h = boot(options);
expect(h.service.read()).toEqual({ enabled: false, available: false, status: 'unavailable' });
expect(() => h.service.setEnabled(true)).toThrow('E_STARTUP_UNAVAILABLE');
expect(h.get).not.toHaveBeenCalled(); expect(h.set).not.toHaveBeenCalled(); expect(h.createWindow).toHaveBeenCalledWith(false);
});
it('passes the installed Windows executable, startup argument and approval flag to the actual Electron boundary only on consent', () => {
const h = boot(); expect(h.set).not.toHaveBeenCalled();
expect(h.service.setEnabled(true).enabled).toBe(true);
expect(h.set).toHaveBeenLastCalledWith({ path: h.executable, args: ['--orkas-startup'], openAtLogin: true, enabled: true });
expect(h.get).toHaveBeenLastCalledWith({ path: `"${h.executable}"`, args: ['--orkas-startup'] });
expect(h.service.setEnabled(false).enabled).toBe(false);
expect(h.set).toHaveBeenLastCalledWith({ path: h.executable, args: ['--orkas-startup'], openAtLogin: false, enabled: false });
});
it('uses the macOS application service without Windows arguments or obsolete openAsHidden', () => {
const h = boot({ platform: 'darwin' }); expect(h.set).not.toHaveBeenCalled();
expect(h.service.setEnabled(true).enabled).toBe(true);
expect(h.set).toHaveBeenCalledWith({ openAtLogin: true }); expect(h.get).toHaveBeenLastCalledWith(undefined);
});
it.each([
{ platform: 'darwin', atLogin: true, background: true, hidden: true },
{ platform: 'darwin', atLogin: true, background: false, hidden: true },
{ platform: 'darwin', atLogin: false, background: true, hidden: false },
{ platform: 'win32', argv: ['--orkas-startup'], background: true, hidden: true },
{ platform: 'win32', argv: ['--orkas-startup'], background: false, hidden: true },
{ platform: 'win32', argv: [], background: true, hidden: false },
])('system startup is hidden regardless of the retired background choice %j', options => {
const h = boot(options); expect(h.createWindow).toHaveBeenCalledExactlyOnceWith(options.hidden);
expect(h.set).not.toHaveBeenCalled(); expect(h.showMainWindow).not.toHaveBeenCalled(); expect(h.background).not.toHaveBeenCalled();
});
it('retains window recovery when Windows tray initialization fails', () => {
const h = boot({ platform: 'win32', argv: ['--orkas-startup'], tray: false });
expect(h.createWindow).toHaveBeenCalledWith(true); expect(h.showMainWindow).toHaveBeenCalledOnce();
expect(h.set).not.toHaveBeenCalled();
});
it('shows the window on a macOS launch-context read failure without granting startup consent', () => {
const h = boot({ platform: 'darwin', failRead: true });
expect(h.createWindow).toHaveBeenCalledWith(false); expect(h.warn).toHaveBeenCalledOnce();
expect(h.set).not.toHaveBeenCalled(); expect(() => h.service.read()).toThrow('E_STARTUP_READ');
});
});
+57 -11
View File
@@ -1,6 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const trayMock = vi.hoisted(() => ({
buildFromTemplate: vi.fn(template => ({ items: template })),
popUpContextMenu: vi.fn(),
events: {} as Record<string, () => void>,
createFromPath: vi.fn(),
isEmpty: vi.fn(),
setTemplateImage: vi.fn(),
@@ -12,9 +15,11 @@ const trayMock = vi.hoisted(() => ({
}));
vi.mock('electron', () => ({
Menu: { buildFromTemplate: trayMock.buildFromTemplate },
nativeImage: { createFromPath: trayMock.createFromPath },
Tray: class {
constructor(image: unknown) { trayMock.construct(image); }
popUpContextMenu = trayMock.popUpContextMenu;
setToolTip = trayMock.setToolTip;
on = trayMock.on;
destroy = trayMock.destroy;
@@ -26,6 +31,8 @@ import { createOptionalTray } from '../../../src/main/util/app-tray';
describe('optional desktop tray', () => {
beforeEach(() => {
vi.resetAllMocks();
trayMock.events = {};
trayMock.buildFromTemplate.mockImplementation(template => ({ items: template }));
trayMock.click = null;
trayMock.isEmpty.mockReturnValue(false);
trayMock.createFromPath.mockReturnValue({
@@ -33,22 +40,30 @@ describe('optional desktop tray', () => {
setTemplateImage: trayMock.setTemplateImage,
});
trayMock.on.mockImplementation((event: string, callback: () => void) => {
trayMock.events[event] = callback;
if (event === 'click') trayMock.click = callback;
});
});
const quit = vi.fn();
let labels = { open: '打开', quit: '退出' };
const create = (platform: NodeJS.Platform = 'win32', onActivate = vi.fn(), warn = vi.fn()) =>
createOptionalTray({ platform, iconPath: '/app/icon.ico', displayName: 'Orkas', onActivate, warn });
createOptionalTray({ platform, iconPath: '/app/icon.ico', displayName: 'Orkas', onActivate, onQuit: quit, labels: () => labels, warn });
it('keeps a usable activation entry when the icon and tray are available', () => {
const onActivate = vi.fn();
expect(create('win32', onActivate)).not.toBeNull();
expect(trayMock.createFromPath).toHaveBeenCalledWith('/app/icon.ico');
expect(trayMock.setToolTip).toHaveBeenCalledWith('Orkas');
expect(trayMock.click).not.toBeNull();
trayMock.click?.();
expect(onActivate).toHaveBeenCalledOnce();
expect(trayMock.setTemplateImage).not.toHaveBeenCalled();
it.each(['darwin', 'win32'] as const)('offers exactly Open and Quit for left and right clicks on %s; opening never quits', platform => {
labels = { open: '打开', quit: '退出' };
const onActivate = vi.fn(); expect(create(platform, onActivate)).not.toBeNull();
for (const event of ['click', 'right-click']) {
trayMock.events[event]();
const menu = trayMock.popUpContextMenu.mock.calls.at(-1)![0];
expect(menu.items.map(item => item.label)).toEqual(['打开', '退出']);
expect(onActivate).not.toHaveBeenCalled(); expect(quit).not.toHaveBeenCalled();
menu.items[0].click(); expect(onActivate).toHaveBeenCalledOnce(); onActivate.mockClear();
}
labels = { open: 'Open', quit: 'Quit' }; trayMock.events.click();
const menu = trayMock.popUpContextMenu.mock.calls.at(-1)![0];
expect(menu.items.map(item => item.label)).toEqual(['Open', 'Quit']);
menu.items[1].click(); expect(quit).toHaveBeenCalledOnce(); expect(onActivate).not.toHaveBeenCalled();
});
it('uses a template image only for the macOS menu bar', () => {
@@ -85,15 +100,46 @@ describe('optional desktop tray', () => {
expect(warn).toHaveBeenCalledWith('tray unavailable');
});
it('keeps both actions unexecuted after a native popup failure and allows the next click to retry', () => {
const warn = vi.fn(), activate = vi.fn();
create('win32', activate, warn);
trayMock.popUpContextMenu.mockImplementationOnce(() => { throw new Error('controlled popup failure'); });
trayMock.events.click();
expect(activate).not.toHaveBeenCalled(); expect(quit).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledExactlyOnceWith('tray menu unavailable');
trayMock.events['right-click']();
expect(trayMock.popUpContextMenu).toHaveBeenCalledTimes(2);
trayMock.popUpContextMenu.mock.calls.at(-1)![0].items[0].click();
expect(activate).toHaveBeenCalledOnce(); expect(quit).not.toHaveBeenCalled();
});
it('contains a window activation failure so a later click can retry', () => {
const warn = vi.fn();
const onActivate = vi.fn()
.mockImplementationOnce(() => { throw new Error('window unavailable'); })
.mockImplementationOnce(() => {});
expect(create('win32', onActivate, warn)).not.toBeNull();
expect(() => trayMock.click?.()).not.toThrow();
trayMock.click?.();
const activate = trayMock.popUpContextMenu.mock.calls.at(-1)![0].items[0].click;
expect(() => activate()).not.toThrow();
activate();
expect(onActivate).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenCalledExactlyOnceWith('tray activation unavailable');
});
it('keeps Quit retryable even when both shutdown initiation and its diagnostic fail', () => {
const warn = vi.fn(() => { throw new Error('controlled diagnostic failure'); });
quit.mockImplementationOnce(() => { throw new Error('controlled quit failure'); });
const activate = vi.fn();
expect(create('win32', activate, warn)).not.toBeNull();
trayMock.events['right-click']();
const requestQuit = trayMock.popUpContextMenu.mock.calls.at(-1)![0].items[1].click;
expect(() => requestQuit()).not.toThrow();
expect(warn).toHaveBeenCalledExactlyOnceWith('tray quit unavailable');
expect(activate).not.toHaveBeenCalled();
trayMock.events.click();
trayMock.popUpContextMenu.mock.calls.at(-1)![0].items[1].click();
expect(quit).toHaveBeenCalledTimes(2);
expect(activate).not.toHaveBeenCalled();
});
});
@@ -124,6 +124,18 @@ describe('packaged-entrypoint-gate', () => {
expect(gate.PACKAGED_BIN_ENTRYPOINTS).not.toContain('shopify-setup-requirements.cjs');
});
// A duplicate survives the classification check because both sides repeat it,
// so a later edit to one copy would leave the other silently in force.
it('registers each bin file and package exclusion exactly once', () => {
const repeated = (entries: readonly string[]) => entries.filter((entry, index) => entries.indexOf(entry) !== index);
expect(repeated([
...gate.PACKAGED_BIN_ENTRYPOINTS, ...gate.PACKAGED_BIN_HELPERS,
...gate.BUILD_ONLY_BIN_FILES, ...gate.DORMANT_BIN_FILES,
])).toEqual([]);
const manifest = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'));
expect(repeated(manifest.build.files)).toEqual([]);
});
it('loads the bridge SDK closure outside the source tree and rejects a missing shared validator', () => {
const root = packagedRuntimeFixture();
fs.copyFileSync(path.join(process.cwd(), 'package-lock.json'), path.join(root, 'package-lock.json'));
+68 -3
View File
@@ -52,6 +52,7 @@ describe('application shutdown durability and latency', () => {
'./util/boot_init': { stopBootWork: mark('boot-work') },
'./features/library_corpus': { closeAllLibraryCorpora: mark('library-owners') },
'./features/local_agents/runner.js': { stopBackgroundRuns: mark('background-cli') },
'../core-agent/src/tools/process-session': { stopAllProcessSessions: mark('process-sessions') },
'./features/kb_vector': { closeAllKb: mark('kb-database') },
'./features/kb_embed': { closeEmbedder: mark('embed-worker') },
'./util/sharp-runtime': { closeImageRuntime: mark('image-worker') },
@@ -73,7 +74,10 @@ describe('application shutdown durability and latency', () => {
events.push('capture');
return { terminate: () => { events.push('terminate'); return exited.promise; }, dispose: mark('dispose') };
},
autoTasks: { stopScheduler: mark('auto-scheduler') },
projectDriverRunner: { stopDriverScheduler: mark('driver-scheduler') },
prepareForShutdown: () => { events.push('archive'); return archived.promise; },
resumeAfterFailedShutdown: vi.fn(),
connectorsFeature: { shutdown: mark('connectors') },
searchFeature: { flushAll: mark('search-workers') },
openLifecycleTracking: { flushQuit: () => { events.push('open-lifecycle'); return lifecycleSaved.promise; } },
@@ -83,8 +87,8 @@ describe('application shutdown durability and latency', () => {
const shutdown = mod.exports;
const pending = shutdown.prepare(mode);
expect(shutdown.prepare(mode)).toBe(pending);
await vi.waitFor(() => expect(events).toContain('background-cli'));
expect(events).toEqual(mode === 'quit' ? ['capture', 'archive', 'background-cli'] : ['archive', 'background-cli']);
await vi.waitFor(() => expect(events).toContain('process-sessions'));
expect(events).toEqual(mode === 'quit' ? ['capture', 'auto-scheduler', 'driver-scheduler', 'archive', 'background-cli', 'process-sessions'] : ['auto-scheduler', 'driver-scheduler', 'archive', 'background-cli', 'process-sessions']);
archived.release();
await vi.waitFor(() => expect(events).toContain('browser-save'));
expect(events.indexOf('library-owners')).toBeLessThan(events.indexOf('kb-database'));
@@ -110,11 +114,72 @@ describe('application shutdown durability and latency', () => {
exited.release();
}
await pending;
const owners = ['archive', 'background-cli', 'boot-work', 'library-owners', 'connectors', 'search-workers', 'kb-database', 'embed-worker', 'image-worker', 'office-extract', 'office', 'browser-save', 'open-lifecycle', 'app-close', 'app-databases'];
const owners = ['auto-scheduler', 'driver-scheduler', 'archive', 'background-cli', 'process-sessions', 'boot-work', 'library-owners', 'connectors', 'search-workers', 'kb-database', 'embed-worker', 'image-worker', 'office-extract', 'office', 'browser-save', 'open-lifecycle', 'app-close', 'app-databases'];
expect([...events].sort()).toEqual([...owners, ...(mode === 'quit' ? ['capture', 'terminate', 'dispose'] : [])].sort());
expect(shutdown.complete).toBe(true);
});
function realComposition(events: string[], overrides: {
terminate?: () => Promise<void>;
stopAllProcessSessions?: () => Promise<void>;
} = {}) {
const source = fs.readFileSync(path.resolve('src/main/index.ts'), 'utf8');
const parsed = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, true);
let composition = '';
const visit = (node: ts.Node) => {
if (ts.isCallExpression(node) && node.expression.getText(parsed) === 'createShutdownCoordinator') composition = node.getText(parsed);
ts.forEachChild(node, visit);
};
visit(parsed);
const mark = (name: string) => () => { events.push(name); };
const dependencies: Record<string, unknown> = {
'./util/boot_init': { stopBootWork: vi.fn(), resumeBootAdmission: mark('boot') },
'./features/library_corpus': { closeAllLibraryCorpora: vi.fn(), resumeLibraryAdmission: async () => { events.push('library'); } },
'./features/local_agents/runner.js': { stopBackgroundRuns: vi.fn() },
'../core-agent/src/tools/process-session': { stopAllProcessSessions: overrides.stopAllProcessSessions || vi.fn() },
'./features/kb_vector': { closeAllKb: vi.fn() },
'./features/kb_embed': { closeEmbedder: vi.fn() },
'./util/sharp-runtime': { closeImageRuntime: vi.fn() },
'./util/office-extract': { closeOfficeExtraction: vi.fn() },
'./features/office/office_engine': { closeAllOfficeResidents: vi.fn() },
'./features/web_assist_session': { flushWebAssistSessions: vi.fn() },
'./features/web_apps/host': { runtime: { closeUser: vi.fn(), drainData: vi.fn() } },
};
const mod = { exports: {} as ReturnType<typeof createShutdownCoordinator> };
vm.runInNewContext(ts.transpileModule(`module.exports = ${composition};`, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText, {
module: mod, createShutdownCoordinator,
require: (name: string) => { if (!(name in dependencies)) throw new Error(`Uncontrolled shutdown dependency: ${name}`); return dependencies[name]; },
captureWindowsShutdownProcesses: async () => ({ terminate: overrides.terminate || vi.fn(), dispose: vi.fn() }),
autoTasks: { stopScheduler: vi.fn(), startScheduler: mark('auto-scheduler') },
projectDriverRunner: { stopDriverScheduler: vi.fn(), startDriverScheduler: mark('driver-scheduler') },
prepareForShutdown: async () => {},
resumeAfterFailedShutdown: async () => { events.push('task-admission'); },
connectorsFeature: { shutdown: vi.fn() },
searchFeature: { flushAll: vi.fn() },
openLifecycleTracking: null,
users: { getActiveUserId: () => 'isolated-owner' },
createLogger: () => ({ warn: mark('process-sessions-warned') }),
});
return mod.exports;
}
it('reopens task admission before every other owner when the real wiring fails to exit', async () => {
const events: string[] = [];
const shutdown = realComposition(events, { terminate: async () => { throw new Error('controlled timeout'); } });
await expect(shutdown.prepare()).rejects.toMatchObject({ code: 'E_SHUTDOWN_PROCESSES_FAILED' });
expect(events).toEqual(['task-admission', 'library', 'boot', 'auto-scheduler', 'driver-scheduler']);
});
it('still exits when stopping retained commands fails, because no user data depends on it', async () => {
const events: string[] = [];
const shutdown = realComposition(events, { stopAllProcessSessions: async () => { throw new Error('controlled failure'); } });
await shutdown.prepare();
expect(shutdown.complete).toBe(true);
expect(events).toEqual(['process-sessions-warned']);
});
it('pins owned processes before stopping producers and confirms their exit before installer handoff', async () => {
const exited = gate();
const order: string[] = [];
+1 -1
View File
@@ -82,7 +82,7 @@ function fixture(options: {
rmSync(p: string) { p = norm(p); operations.push(['rm', p]); files.delete(p); },
};
function boot() {
const env: Record<string, string> = { LOCALAPPDATA: 'C:\\Profile', SystemDrive: 'C:', SystemRoot: 'C:\\Windows', ORKAS_RUNTIME_DEV: '0' };
const env: Record<string, string> = { LOCALAPPDATA: 'C:\\Profile', SystemDrive: 'C:', SystemRoot: 'C:\\Windows' };
const module = { exports: {} };
const requireMock = (id: string): any => {
if (id === 'node:fs') return fsMock;
+184 -23
View File
@@ -1,68 +1,135 @@
import { readFileSync } from 'node:fs';
import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, unlinkSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import ts from 'typescript';
import { spawn } from 'node:child_process';
import { EventEmitter } from 'node:events';
import { expect, it, vi } from 'vitest';
import { closeElectronFixture, requestElectronQuit } from '../e2e/fixtures/electron-cleanup';
import { analyzeWebAppLogs } from '../e2e/fixtures/web-app-log-analysis';
function evaluateQuit(callback: (electron: any) => void, electron: any,
inspector: { close(): void } | (() => never) = { close: vi.fn() }): void {
// Playwright serializes the callback into the application's Node context.
// Keep its inspector isolated from the Vitest process in this control.
new Function('process', 'setImmediate', 'electron', `return (${callback.toString()})(electron);`)(
{ mainModule: { require: (name: string) => {
expect(name).toBe('node:inspector');
return typeof inspector === 'function' ? inspector() : inspector;
} } }, setImmediate, electron,
);
}
it.each([false, true])('isolates Chromium stub traffic while preserving explicit proxy coverage (proxy: %s)', async systemProxyRouting => {
const source = ts.createSourceFile('orkas.ts', readFileSync(
path.join(__dirname, '../e2e/fixtures/orkas.ts'), 'utf8'), ts.ScriptTarget.Latest, true);
const owner = source.statements.find((node): node is ts.ClassDeclaration =>
ts.isClassDeclaration(node) && node.name?.text === 'OrkasTestApp');
const launch = owner?.members.find(member => member.name?.getText(source) === 'launch');
if (!launch) throw new Error('Missing fixture launch owner');
const code = ts.transpileModule(`class LaunchOwner { ${launch.getText(source)} }`, {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
}).outputText;
const reachedLaunch = new Error('launch boundary reached');
const launchApplication = vi.fn(async (_options: { args: string[]; env: Record<string, string> }) => { throw reachedLaunch; });
const Owner = new Function('electron', 'PC_ROOT', 'path', '__dirname', 'process', `${code}; return LaunchOwner;`)(
{ launch: launchApplication }, '/fixture/pc', path, '/fixture', { platform: process.platform, env: {} });
const fixture = Object.assign(new Owner(), {
root: '/fixture', options: {}, enableSystemProxy: systemProxyRouting, apiBaseUrl: 'http://127.0.0.1:12345/api',
workspaceRoot: '/fixture/workspace', userDataRoot: '/fixture/user-data', authRoot: '/fixture/auth',
ensureStubServer: async () => {},
});
await expect(fixture.launch()).rejects.toBe(reachedLaunch);
const invocation = launchApplication.mock.calls[0][0] as { args: string[]; env: Record<string, string> };
expect(invocation.args.includes('--no-proxy-server')).toBe(!systemProxyRouting);
expect(invocation.env.ORKAS_NO_AUTO_PROXY).toBe(systemProxyRouting ? '0' : '1');
});
it('defers graceful quit outside evaluation and waits for the real close event', async () => {
const app = new EventEmitter() as any;
app.windows = () => [{}];
app.windows = () => [{ isClosed: () => false, evaluate: vi.fn(async () => {}) }];
app.close = vi.fn(async () => {});
let evaluating = false;
let requested = false;
let finished = false;
app.evaluate = async (callback: any) => {
evaluating = true;
callback({ app: { quit: () => { expect(evaluating).toBe(false); requested = true; } } });
evaluateQuit(callback, { app: { quit: () => { expect(evaluating).toBe(false); requested = true; } } });
expect(requested).toBe(false);
evaluating = false;
};
const completion = requestElectronQuit(app).then(() => { finished = true; });
await new Promise(resolve => setImmediate(resolve));
expect(requested).toBe(true);
await vi.waitFor(() => expect(requested).toBe(true));
expect(finished).toBe(false);
app.emit('close');
await completion;
expect(app.listenerCount('close')).toBe(0);
});
it('preserves quit request errors and removes the close observer', async () => {
const app = new EventEmitter() as any;
app.windows = () => [{}];
app.windows = () => [{ isClosed: () => false, evaluate: vi.fn(async () => {}) }];
app.evaluate = async () => { throw new Error('inspector unavailable'); };
await expect(requestElectronQuit(app)).rejects.toThrow('inspector unavailable');
expect(app.listenerCount('close')).toBe(0);
});
it('releases the inspector after deferred quit so the real application can exit', async () => {
it('detaches the inspector before one deferred quit without asking Playwright to quit again', async () => {
const app = new EventEmitter() as any;
app.windows = () => [{}];
app.windows = () => [{ isClosed: () => false, evaluate: vi.fn(async () => {}) }];
let quit!: () => void;
const quitting = new Promise<void>(resolve => { quit = resolve; });
app.evaluate = async (callback: any) => { callback({ app: { quit } }); };
const steps: string[] = [];
const inspector = { close: vi.fn(() => { steps.push('detach'); }) };
app.evaluate = async (callback: any) => {
evaluateQuit(callback, { app: { quit: () => { steps.push('quit'); quit(); app.emit('close'); } } }, inspector);
};
app.close = vi.fn(async () => { await quitting; app.emit('close'); });
await requestElectronQuit(app);
expect(app.close).toHaveBeenCalledTimes(1);
expect(steps).toEqual(['detach', 'quit']);
expect(inspector.close).toHaveBeenCalledTimes(1);
expect(app.close).not.toHaveBeenCalled();
expect(app.listenerCount('close')).toBe(0);
});
it('preserves inspector detach failures instead of reporting graceful shutdown', async () => {
it('preserves inspector setup failures instead of reporting graceful shutdown', async () => {
const app = new EventEmitter() as any;
const failure = new Error('inspector detach failed');
app.windows = () => [{}];
app.evaluate = async (callback: any) => { callback({ app: { quit: () => {} } }); };
app.close = async () => { throw failure; };
app.windows = () => [{ isClosed: () => false, evaluate: vi.fn(async () => {}) }];
const quit = vi.fn();
app.evaluate = async (callback: any) => evaluateQuit(callback, { app: { quit } }, () => { throw failure; });
app.close = vi.fn();
await expect(requestElectronQuit(app)).rejects.toBe(failure);
expect(quit).not.toHaveBeenCalled();
expect(app.close).not.toHaveBeenCalled();
expect(app.listenerCount('close')).toBe(0);
});
it('waits for real close when quitting destroys the inspector before the close event', async () => {
it.each([
'electronApplication.evaluate: Execution context was destroyed, most likely because of a navigation.',
'electronApplication.evaluate: Target page, context or browser has been closed',
])('waits for real process close after an inspector-disposal race: %s', async message => {
const app = new EventEmitter() as any;
app.windows = () => [{}];
app.windows = () => [{ isClosed: () => false, evaluate: vi.fn(async () => {}) }];
app.close = vi.fn(async () => {});
app.evaluate = async () => { throw new Error('electronApplication.evaluate: Execution context was destroyed, most likely because of a navigation.'); };
app.evaluate = async () => { throw new Error(message); };
let finished = false;
const completion = requestElectronQuit(app).then(() => { finished = true; });
// Observe rejection immediately as well, so the negative control is bounded.
@@ -75,6 +142,7 @@ it('waits for real close when quitting destroys the inspector before the close e
expect(app.listenerCount('close')).toBe(0);
});
it.skipIf(process.platform === 'darwin')('waits for natural shutdown after the final window closes', async () => {
const app = new EventEmitter() as any;
const child = new EventEmitter() as any;
@@ -93,6 +161,7 @@ it.skipIf(process.platform === 'darwin')('waits for natural shutdown after the f
expect(child.listenerCount('exit')).toBe(0);
});
it('captures ownership before a successful close disposes the application object', async () => {
let closed = false;
const child = { exitCode: 0, signalCode: null } as any;
@@ -104,6 +173,7 @@ it('captures ownership before a successful close disposes the application object
expect(closed).toBe(true);
});
it('bounds stuck tracing and shutdown, stops only the owned process and preserves the failure', async () => {
const node = process.env.ORKAS_TEST_NODE || process.execPath;
const start = () => spawn(node, ['-e', 'setInterval(() => {}, 1000)'], {
@@ -132,6 +202,7 @@ it('bounds stuck tracing and shutdown, stops only the owned process and preserve
}
});
it('retains the original application-close error as the cleanup failure cause', async () => {
const original = new Error('injected inspector close failure');
const result = closeElectronFixture({
@@ -144,6 +215,7 @@ it('retains the original application-close error as the cleanup failure cause',
});
});
// Exercise the actual owner method without loading Playwright's fixture runner.
// A timeout may overlap relaunch with teardown; neither may duplicate trace/quit
// or clear an application that has since replaced the captured instance.
@@ -154,7 +226,7 @@ function createCleanupOwner(app: any) {
ts.isClassDeclaration(node) && node.name?.text === 'OrkasTestApp');
if (!owner) throw new Error('Missing fixture owner');
const members = owner.members.filter(member =>
member.name && ['closeCurrentApp', 'appClosePromises', 'appTraceStops', 'nativeShutdownProcesses', 'stopAppTrace', 'prepareForNativeShutdown', 'launchAfterNativeExit', 'launchAfterCrash'].includes(member.name.getText(file)));
member.name && ['closeCurrentApp', 'appClosePromises', 'appTraceStops', 'appProcesses', 'nativeShutdownProcesses', 'stopAppTrace', 'prepareForNativeShutdown', 'launchAfterNativeExit', 'launchAfterCrash'].includes(member.name.getText(file)));
const code = ts.transpileModule(`class CleanupOwner { ${members.map(m => m.getText(file)).join('\n')} }`, {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
}).outputText;
@@ -166,22 +238,26 @@ function createCleanupOwner(app: any) {
});
}
function controlledCleanupApp(closeError?: Error) {
let release!: () => void;
const tracingGate = new Promise<void>(resolve => { release = resolve; });
const app = new EventEmitter() as any;
app.windows = () => [{}];
app.close = vi.fn(async () => {});
app.process = () => ({ exitCode: 0, signalCode: null });
const child = Object.assign(new EventEmitter(), { exitCode: null as number | null, signalCode: null as string | null,
kill: (signal: string) => { child.signalCode = signal; child.emit('exit', null, signal); return true; } });
app.windows = () => [{ isClosed: () => false, evaluate: vi.fn(async () => {}) }];
app.close = vi.fn(async () => { child.exitCode = 0; });
app.process = () => child;
const stop = vi.fn(() => tracingGate);
app.context = () => ({ tracing: { stop } });
app.evaluate = vi.fn(async (callback: any) => {
if (closeError) throw closeError;
callback({ app: { quit: () => app.emit('close') } });
evaluateQuit(callback, { app: { quit: () => { child.exitCode = 0; child.emit('exit', 0); app.emit('close'); } } });
});
return { app, stop, release };
}
it('shares one trace and graceful shutdown when relaunch and teardown overlap', async () => {
const { app, stop, release } = controlledCleanupApp();
const owner = createCleanupOwner(app);
@@ -196,6 +272,7 @@ it('shares one trace and graceful shutdown when relaunch and teardown overlap',
expect(owner.page).toBeNull();
});
it('exports tracing before native shutdown and reuses that export during teardown', async () => {
const { app, stop, release } = controlledCleanupApp();
const owner = createCleanupOwner(app);
@@ -209,6 +286,22 @@ it('exports tracing before native shutdown and reuses that export during teardow
expect(app.evaluate).toHaveBeenCalledTimes(1);
});
it('uses launch-time process ownership after a user exit disposes the Playwright application', async () => {
const { app, release } = controlledCleanupApp();
const owner = createCleanupOwner(app);
const child = { exitCode: 0, signalCode: null } as any;
owner.appProcesses = new WeakMap([[app, child]]);
release(); await owner.prepareForNativeShutdown();
app.process = () => { throw new Error('Application object already disposed'); };
app.context = () => { throw new Error('Browser context already disposed'); };
await expect(owner.closeCurrentApp()).resolves.toBeUndefined();
expect(app.evaluate).not.toHaveBeenCalled();
expect(app.close).not.toHaveBeenCalled();
expect(owner.electronApp).toBeNull();
});
it('delivers the same cleanup failure and original cause to both overlapping callers', async () => {
const cause = new Error('injected owner close failure');
const { app, release } = controlledCleanupApp(cause);
@@ -224,6 +317,7 @@ it('delivers the same cleanup failure and original cause to both overlapping cal
expect(owner.electronApp).toBeNull();
});
it('leaves a replacement application and page owned after the old close finishes', async () => {
const { app, release } = controlledCleanupApp();
const owner = createCleanupOwner(app);
@@ -241,10 +335,64 @@ it('leaves a replacement application and page owned after the old close finishes
});
it.each(['success', 'application-error', 'cleanup-error'] as const)(
'retains the canonical complete log for downstream analyzers after %s in checkpoint mode', async scenario => {
const file = ts.createSourceFile('orkas.ts', readFileSync(
path.join(__dirname, '../e2e/fixtures/orkas.ts'), 'utf8'), ts.ScriptTarget.Latest, true);
const owner = file.statements.find((node): node is ts.ClassDeclaration =>
ts.isClassDeclaration(node) && node.name?.text === 'OrkasTestApp');
const dispose = owner?.members.find(member => member.name?.getText(file) === 'dispose');
if (!dispose) throw new Error('Missing fixture disposal owner');
const code = ts.transpileModule(`class DisposalOwner { ${dispose.getText(file)} }`, {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
}).outputText;
const dependencies = { mkdirSync, writeFileSync, unlinkSync, rmSync, randomUUID, path, Buffer,
analyzeWebAppLogs, collectLogFiles: () => '[fixture-cleanup] complete\n', expect };
const Owner = new Function(...Object.keys(dependencies), `${code}; return DisposalOwner;`)(...Object.values(dependencies));
const root = mkdtempSync(path.join(tmpdir(), 'electron-log-retention-'));
const fixtureRoot = path.join(root, 'fixture');
const outputDir = path.join(root, 'output');
mkdirSync(fixtureRoot);
const raw = scenario === 'application-error'
? '[main:stderr] [12:00:00.000] [error] [(fixture)] controlled failure'
: '[main:stdout] complete application evidence';
const failure = new Error('controlled cleanup failure');
const testInfo = { status: 'passed', expectedStatus: 'passed', outputDir,
outputPath: (name: string) => path.join(outputDir, name), attach: vi.fn(async () => {}) };
const app = Object.assign(new Owner(), { testInfo, root: fixtureRoot, page: null,
resourceDenials: new Map(), browserDenials: new Map(), rendererPageErrors: [],
recoveredRendererErrors: new Set(), diagnostics: [raw], tracePaths: [],
expectedInvokeFailures: [], expectedRendererFailures: new Map(), expectedApiFailures: [], apiResponses: [],
recoveredDiagnosticRanges: () => [],
closeCurrentApp: async () => { if (scenario === 'cleanup-error') throw failure; },
closeStubServer: async () => {},
});
vi.stubEnv('ORKAS_TEST_BATCH_REPORT', 'controlled-checkpoint.json');
try {
if (scenario === 'application-error') await expect(app.dispose()).rejects.toThrow('Unexpected Electron diagnostics');
else if (scenario === 'cleanup-error') await expect(app.dispose()).rejects.toBe(failure);
else await app.dispose();
const canonical = testInfo.outputPath('electron-main.log');
expect(existsSync(canonical), 'A failure must retain the same canonical path as success').toBe(true);
expect(readFileSync(canonical, 'utf8')).toBe(`${raw}\n[fixture-cleanup] complete\n`);
expect(existsSync(fixtureRoot), 'Owned resources are cleaned even when evidence fails').toBe(false);
const analysis = JSON.parse(readFileSync(testInfo.outputPath('electron-log-analysis.json'), 'utf8'));
expect(analysis.passed).toBe(scenario !== 'application-error');
expect(analysis.coveragePassed).toBe(true);
expect(testInfo.attach).toHaveBeenCalledWith('electron-logs', expect.objectContaining({ path: expect.any(String) }));
} finally {
vi.unstubAllEnvs();
rmSync(root, { recursive: true, force: true });
}
});
it('reopens through fixture ownership after prepared clean native exit without a fallback quit', async () => {
const { app, stop, release } = controlledCleanupApp();
const owner = createCleanupOwner(app);
const child = app.process();
child.exitCode = 0;
app.process = vi.fn(() => child);
release();
await owner.prepareForNativeShutdown();
@@ -269,6 +417,7 @@ it('reopens through fixture ownership after prepared clean native exit without a
expect(owner.page).toBe(replacementPage);
});
it.each([
{ exitCode: null, signalCode: null },
{ exitCode: 2, signalCode: null },
@@ -289,8 +438,10 @@ it.each([
expect(app.close).not.toHaveBeenCalled();
});
it('requires native trace preparation and preserves its export failure', async () => {
const { app } = controlledCleanupApp();
app.process().exitCode = 0;
const owner = createCleanupOwner(app);
const page = owner.page;
owner.launch = vi.fn();
@@ -306,8 +457,10 @@ it('requires native trace preparation and preserves its export failure', async (
expect(app.close).not.toHaveBeenCalled();
});
it('preserves a replacement owner when native trace export settles late', async () => {
const { app, release } = controlledCleanupApp();
app.process().exitCode = 0;
const owner = createCleanupOwner(app);
owner.launch = vi.fn();
const exporting = owner.prepareForNativeShutdown();
@@ -327,8 +480,10 @@ it('preserves a replacement owner when native trace export settles late', async
expect(app.close).not.toHaveBeenCalled();
});
it('does not bypass cleanup already owned by teardown after native exit', async () => {
const { app, release } = controlledCleanupApp();
app.process().exitCode = 0;
const owner = createCleanupOwner(app);
owner.launch = vi.fn();
release();
@@ -339,6 +494,7 @@ it('does not bypass cleanup already owned by teardown after native exit', async
expect(owner.launch).not.toHaveBeenCalled();
});
it('reopens only the prepared SIGKILL owner without querying its disposed channels or issuing quit', async () => {
const { app, stop, release } = controlledCleanupApp();
const child = { exitCode: null, signalCode: null as string | null };
@@ -364,6 +520,7 @@ it('reopens only the prepared SIGKILL owner without querying its disposed channe
for (const operation of [app.process, app.context, app.evaluate, app.close]) expect(operation).not.toHaveBeenCalled();
});
it.each([
{ exitCode: null, signalCode: null },
{ exitCode: 0, signalCode: null },
@@ -386,6 +543,7 @@ it.each([
expect(app.close).not.toHaveBeenCalled();
});
it('requires captured crash ownership and retains trace export failures without clearing the app', async () => {
const { app } = controlledCleanupApp();
app.process = () => ({ exitCode: null, signalCode: 'SIGKILL' });
@@ -405,6 +563,7 @@ it('requires captured crash ownership and retains trace export failures without
expect(app.close).not.toHaveBeenCalled();
});
it('preserves a replacement owner while the crashed app trace export settles', async () => {
const { app, release } = controlledCleanupApp();
app.process = () => ({ exitCode: null, signalCode: 'SIGKILL' });
@@ -426,6 +585,7 @@ it('preserves a replacement owner while the crashed app trace export settles', a
expect(app.close).not.toHaveBeenCalled();
});
it.each(['before', 'during'])('preserves teardown ownership acquired %s crash trace completion', async when => {
const { app, release } = controlledCleanupApp();
app.process = () => ({ exitCode: null, signalCode: 'SIGKILL' });
@@ -445,6 +605,7 @@ it.each(['before', 'during'])('preserves teardown ownership acquired %s crash tr
expect(app.close).not.toHaveBeenCalled();
});
it('allows at most one concurrent crash reopen after the same trace export', async () => {
const { app, release } = controlledCleanupApp();
app.process = () => ({ exitCode: null, signalCode: 'SIGKILL' });
@@ -463,4 +624,4 @@ it('allows at most one concurrent crash reopen after the same trace export', asy
expect(owner.page).toBe(replacementPage);
expect(app.evaluate).not.toHaveBeenCalled();
expect(app.close).not.toHaveBeenCalled();
});
});