feat: implement DevTools lifecycle management to prevent app crashes

Signed-off-by: Vladyslav Zhukovskyi <vzhukovs@redhat.com>
This commit is contained in:
Vladyslav Zhukovskyi 2025-09-29 19:36:08 +03:00
parent 0ae6c2ebaa
commit 68e6a2436c
12 changed files with 1862 additions and 2 deletions

View file

@ -348,4 +348,14 @@ export default [
'sonarjs/media-has-caption': 'off',
},
},
{
files: ['packages/renderer/src/lib/webview/*.svelte'],
rules: {
// TODO: Remove this workaround once eslint-plugin-sonarjs fixes the bug with Svelte reactive statements
// The sonarjs/no-unused-collection rule has a bug when analyzing Svelte files with reactive statements ($webviews)
// causing "Cannot read properties of null (reading 'type')" error during linting
'sonarjs/no-unused-collection': 'off',
},
},
];

View file

@ -212,6 +212,7 @@ import { Exec } from './util/exec.js';
import { getFreePort, getFreePortRange, isFreePort } from './util/port.js';
import { TaskConnectionUtils } from './util/task-connection-utils.js';
import { ViewRegistry } from './view-registry.js';
import { DevToolsManager } from './webview/devtools-manager.js';
import { WebviewRegistry } from './webview/webview-registry.js';
import { WelcomeInit } from './welcome/welcome-init.js';
@ -683,6 +684,7 @@ export class PluginSystem {
container.bind<ImageFilesRegistry>(ImageFilesRegistry).toSelf().inSingletonScope();
container.bind<Troubleshooting>(Troubleshooting).toSelf().inSingletonScope();
container.bind<ContributionManager>(ContributionManager).toSelf().inSingletonScope();
container.bind<DevToolsManager>(DevToolsManager).toSelf().inSingletonScope();
container.bind<WebviewRegistry>(WebviewRegistry).toSelf().inSingletonScope();
const webviewRegistry = container.get<WebviewRegistry>(WebviewRegistry);
@ -2919,6 +2921,15 @@ export class PluginSystem {
this.ipcHandle('viewRegistry:listViewsContributions', async (_listener): Promise<ViewInfoUI[]> => {
return viewRegistry.listViewsContributions();
});
this.ipcHandle('webview:devtools:register', async (_listener, webcontentId: number): Promise<void> => {
return webviewRegistry.registerWebviewDevTools(webcontentId);
});
this.ipcHandle('webview:devtools:cleanup', async (_listener, webcontentId: number): Promise<void> => {
return webviewRegistry.cleanupWebviewDevTools(webcontentId);
});
this.ipcHandle('webviewRegistry:listWebviews', async (_listener): Promise<WebviewInfo[]> => {
return webviewRegistry.listWebviews();
});

View file

@ -0,0 +1,720 @@
/**********************************************************************
* Copyright (C) 2025 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import { BrowserWindow, type WebContents, webContents } from 'electron';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { DevToolsManager } from './devtools-manager.js';
vi.mock('electron', () => ({
webContents: {
fromId: vi.fn(),
},
BrowserWindow: {
getAllWindows: vi.fn(),
},
}));
const mockWebContents = vi.mocked(webContents);
const mockBrowserWindow = vi.mocked(BrowserWindow);
describe('DevToolsManager', () => {
let devToolsManager: DevToolsManager;
beforeEach(() => {
vi.clearAllMocks();
devToolsManager = new DevToolsManager();
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
describe('Core State Management', () => {
test('should initialize with empty DevTools mapping', () => {
expect(devToolsManager.getTrackedDevToolsCount()).toBe(0);
});
test('should track DevTools mapping correctly', async () => {
const mockGuest = {
devToolsWebContents: {
id: 67890,
isDestroyed: vi.fn().mockReturnValue(false),
},
};
mockWebContents.fromId.mockReturnValue(mockGuest as unknown as WebContents);
await devToolsManager.registerDevTools(12345);
expect(devToolsManager.getTrackedDevToolsCount()).toBe(1);
});
test('should clear all tracking', async () => {
const mockGuest = {
devToolsWebContents: {
id: 67890,
isDestroyed: vi.fn().mockReturnValue(false),
},
};
mockWebContents.fromId.mockReturnValue(mockGuest as unknown as WebContents);
await devToolsManager.registerDevTools(12345);
expect(devToolsManager.getTrackedDevToolsCount()).toBe(1);
devToolsManager.clearAllTracking();
expect(devToolsManager.getTrackedDevToolsCount()).toBe(0);
});
});
describe('registerDevTools', () => {
test('should register DevTools when webContents exists and has devToolsWebContents', async () => {
const mockDevToolsWebContents = {
id: 67890,
isDestroyed: vi.fn().mockReturnValue(false),
};
const mockGuest = {
devToolsWebContents: mockDevToolsWebContents,
};
mockWebContents.fromId.mockReturnValue(mockGuest as unknown as WebContents);
await devToolsManager.registerDevTools(12345);
expect(mockWebContents.fromId).toHaveBeenCalledWith(12345);
expect(mockDevToolsWebContents.isDestroyed).toHaveBeenCalled();
expect(devToolsManager.getTrackedDevToolsCount()).toBe(1);
});
test('should handle missing webContents gracefully', async () => {
mockWebContents.fromId.mockReturnValue(undefined);
await devToolsManager.registerDevTools(12345);
expect(devToolsManager.getTrackedDevToolsCount()).toBe(0);
});
test('should handle webContents without devToolsWebContents property', async () => {
const mockGuest = {};
mockWebContents.fromId.mockReturnValue(mockGuest as unknown as WebContents);
await devToolsManager.registerDevTools(12345);
expect(devToolsManager.getTrackedDevToolsCount()).toBe(0);
});
test('should handle destroyed devToolsWebContents', async () => {
const mockDevToolsWebContents = {
id: 67890,
isDestroyed: vi.fn().mockReturnValue(true),
};
const mockGuest = {
devToolsWebContents: mockDevToolsWebContents,
};
mockWebContents.fromId.mockReturnValue(mockGuest as unknown as WebContents);
await devToolsManager.registerDevTools(12345);
expect(devToolsManager.getTrackedDevToolsCount()).toBe(0);
});
test('should handle errors during registration', async () => {
mockWebContents.fromId.mockImplementation(() => {
throw new Error('WebContents access failed');
});
await devToolsManager.registerDevTools(12345);
expect(console.error).toHaveBeenCalledWith('DevToolsManager: error in registerDevTools:', expect.any(Error));
expect(devToolsManager.getTrackedDevToolsCount()).toBe(0);
});
});
describe('cleanupDevTools', () => {
beforeEach(async () => {
const mockGuest = {
devToolsWebContents: {
id: 67890,
isDestroyed: vi.fn().mockReturnValue(false),
},
};
mockWebContents.fromId.mockReturnValue(mockGuest as unknown as WebContents);
await devToolsManager.registerDevTools(12345);
});
test('should cleanup when devToolsId exists and strategy succeeds', async () => {
const mockGuest = {
isDestroyed: vi.fn().mockReturnValue(false),
isDevToolsOpened: vi.fn().mockReturnValue(true),
closeDevTools: vi.fn(),
};
mockWebContents.fromId.mockReturnValue(mockGuest as unknown as WebContents);
await devToolsManager.cleanupDevTools(12345);
expect(mockGuest.closeDevTools).toHaveBeenCalled();
expect(devToolsManager.getTrackedDevToolsCount()).toBe(0);
});
test('should handle missing devToolsId gracefully', async () => {
await devToolsManager.cleanupDevTools(99999); // Non-existent ID
expect(devToolsManager.getTrackedDevToolsCount()).toBe(1); // Original should remain
});
test('should always remove from map even when all strategies fail', async () => {
mockWebContents.fromId.mockReturnValue(undefined); // Strategy 1 & 2 fail
mockBrowserWindow.getAllWindows.mockReturnValue([]); // Strategy 3 fails
await devToolsManager.cleanupDevTools(12345);
expect(console.warn).toHaveBeenCalledWith(
'DevToolsManager: failed to close DevTools using all available methods',
);
expect(devToolsManager.getTrackedDevToolsCount()).toBe(0); // Should still be removed from map
});
test('should handle errors in cleanup strategies', async () => {
mockWebContents.fromId.mockImplementation(() => {
throw new Error('Strategy error');
});
await devToolsManager.cleanupDevTools(12345);
expect(console.error).toHaveBeenCalledWith('DevToolsManager: error closing DevTools window:', expect.any(Error));
expect(devToolsManager.getTrackedDevToolsCount()).toBe(0); // Should still cleanup map
});
});
describe('Strategy 1 - tryCloseDevToolsViaWebContents', () => {
test('should close DevTools via original webContents successfully', async () => {
const mockGuest = {
isDestroyed: vi.fn().mockReturnValue(false),
isDevToolsOpened: vi.fn().mockReturnValue(true),
closeDevTools: vi.fn(),
};
mockWebContents.fromId.mockReturnValue(mockGuest as unknown as WebContents);
const result = await (devToolsManager as unknown as Record<string, (...args: unknown[]) => Promise<boolean>>)[
'tryCloseDevToolsViaWebContents'
]!(12345);
expect(result).toBe(true);
expect(mockGuest.closeDevTools).toHaveBeenCalled();
});
test('should return false when webContents not found', async () => {
mockWebContents.fromId.mockReturnValue(undefined);
const result = await (devToolsManager as unknown as Record<string, (...args: unknown[]) => Promise<boolean>>)[
'tryCloseDevToolsViaWebContents'
]!(12345);
expect(result).toBe(false);
});
test('should return false when webContents is destroyed', async () => {
const mockGuest = {
isDestroyed: vi.fn().mockReturnValue(true),
closeDevTools: vi.fn(),
};
mockWebContents.fromId.mockReturnValue(mockGuest as unknown as WebContents);
const result = await (devToolsManager as unknown as Record<string, (...args: unknown[]) => Promise<boolean>>)[
'tryCloseDevToolsViaWebContents'
]!(12345);
expect(result).toBe(false);
expect(mockGuest.closeDevTools).not.toHaveBeenCalled();
});
test('should return false when DevTools are not opened', async () => {
const mockGuest = {
isDestroyed: vi.fn().mockReturnValue(false),
isDevToolsOpened: vi.fn().mockReturnValue(false),
closeDevTools: vi.fn(),
};
mockWebContents.fromId.mockReturnValue(mockGuest as unknown as WebContents);
const result = await (devToolsManager as unknown as Record<string, (...args: unknown[]) => Promise<boolean>>)[
'tryCloseDevToolsViaWebContents'
]!(12345);
expect(result).toBe(false);
expect(mockGuest.closeDevTools).not.toHaveBeenCalled();
});
});
describe('Strategy 2 - tryCloseDevToolsDirectly', () => {
test('should succeed when close() method works', async () => {
const mockDevToolsWebContents = {
isDestroyed: vi.fn().mockReturnValue(false),
close: vi.fn(),
};
mockWebContents.fromId.mockReturnValue(mockDevToolsWebContents as unknown as WebContents);
const result = await (devToolsManager as unknown as Record<string, (...args: unknown[]) => Promise<boolean>>)[
'tryCloseDevToolsDirectly'
]!(67890);
expect(result).toBe(true);
expect(mockDevToolsWebContents.close).toHaveBeenCalled();
});
test('should fallback to destroy() when close() fails', async () => {
const mockDevToolsWebContents = {
isDestroyed: vi.fn().mockReturnValue(false),
close: vi.fn().mockImplementation(() => {
throw new Error('Close failed');
}),
destroy: vi.fn(),
};
mockWebContents.fromId.mockReturnValue(mockDevToolsWebContents as unknown as WebContents);
const result = await (devToolsManager as unknown as Record<string, (...args: unknown[]) => Promise<boolean>>)[
'tryCloseDevToolsDirectly'
]!(67890);
expect(result).toBe(true);
expect(mockDevToolsWebContents.close).toHaveBeenCalled();
expect(mockDevToolsWebContents.destroy).toHaveBeenCalled();
});
test('should return false when devToolsWebContents not found', async () => {
mockWebContents.fromId.mockReturnValue(undefined);
const result = await (devToolsManager as unknown as Record<string, (...args: unknown[]) => Promise<boolean>>)[
'tryCloseDevToolsDirectly'
]!(67890);
expect(result).toBe(false);
});
test('should return false when devToolsWebContents is destroyed', async () => {
const mockDevToolsWebContents = {
isDestroyed: vi.fn().mockReturnValue(true),
};
mockWebContents.fromId.mockReturnValue(mockDevToolsWebContents as unknown as WebContents);
const result = await (devToolsManager as unknown as Record<string, (...args: unknown[]) => Promise<boolean>>)[
'tryCloseDevToolsDirectly'
]!(67890);
expect(result).toBe(false);
});
test('should return false when both close() and destroy() fail', async () => {
const devToolsWithFailingMethods = {
isDestroyed: vi.fn().mockReturnValue(false),
close: vi.fn().mockImplementation(() => {
throw new Error('close failed');
}),
destroy: vi.fn().mockImplementation(() => {
throw new Error('destroy failed');
}),
};
mockWebContents.fromId.mockReturnValue(devToolsWithFailingMethods as unknown as WebContents);
const result = await (devToolsManager as unknown as Record<string, (...args: unknown[]) => Promise<boolean>>)[
'tryCloseDevToolsDirectly'
]!(67890);
expect(result).toBe(false);
});
});
describe('Strategy 3 - tryCloseDevToolsViaWindow', () => {
test('should find and close matching window', async () => {
const mockWindow = {
isDestroyed: vi.fn().mockReturnValue(false),
webContents: { id: 67890 },
close: vi.fn(),
};
mockBrowserWindow.getAllWindows.mockReturnValue([mockWindow as unknown as BrowserWindow]);
const result = await (devToolsManager as unknown as Record<string, (...args: unknown[]) => Promise<boolean>>)[
'tryCloseDevToolsViaWindow'
]!(67890);
expect(result).toBe(true);
expect(mockWindow.close).toHaveBeenCalled();
});
test('should skip destroyed windows', async () => {
const destroyedWindow = {
isDestroyed: vi.fn().mockReturnValue(true),
close: vi.fn(),
};
mockBrowserWindow.getAllWindows.mockReturnValue([destroyedWindow as unknown as BrowserWindow]);
const result = await (devToolsManager as unknown as Record<string, (...args: unknown[]) => Promise<boolean>>)[
'tryCloseDevToolsViaWindow'
]!(67890);
expect(result).toBe(false);
expect(destroyedWindow.close).not.toHaveBeenCalled();
});
test('should return false when no matching window found', async () => {
const nonMatchingWindow = {
isDestroyed: vi.fn().mockReturnValue(false),
webContents: { id: 99999 },
close: vi.fn(),
};
mockBrowserWindow.getAllWindows.mockReturnValue([nonMatchingWindow as unknown as BrowserWindow]);
const result = await (devToolsManager as unknown as Record<string, (...args: unknown[]) => Promise<boolean>>)[
'tryCloseDevToolsViaWindow'
]!(67890);
expect(result).toBe(false);
});
test('should handle empty windows list', async () => {
mockBrowserWindow.getAllWindows.mockReturnValue([]);
const result = await (devToolsManager as unknown as Record<string, (...args: unknown[]) => Promise<boolean>>)[
'tryCloseDevToolsViaWindow'
]!(67890);
expect(result).toBe(false);
});
test('should handle multiple windows and find correct one', async () => {
const window1 = {
isDestroyed: vi.fn().mockReturnValue(false),
webContents: { id: 11111 },
close: vi.fn(),
};
const window2 = {
isDestroyed: vi.fn().mockReturnValue(false),
webContents: { id: 67890 }, // Matching
close: vi.fn(),
};
const window3 = {
isDestroyed: vi.fn().mockReturnValue(false),
webContents: { id: 33333 },
close: vi.fn(),
};
mockBrowserWindow.getAllWindows.mockReturnValue([window1, window2, window3] as unknown as BrowserWindow[]);
const result = await (devToolsManager as unknown as Record<string, (...args: unknown[]) => Promise<boolean>>)[
'tryCloseDevToolsViaWindow'
]!(67890);
expect(result).toBe(true);
expect(window2.close).toHaveBeenCalled();
expect(window1.close).not.toHaveBeenCalled();
expect(window3.close).not.toHaveBeenCalled();
});
});
describe('Error Handling', () => {
test('should log errors without throwing in registerDevTools', async () => {
mockWebContents.fromId.mockImplementation(() => {
throw new Error('Test error');
});
await expect(devToolsManager.registerDevTools(12345)).resolves.not.toThrow();
expect(console.error).toHaveBeenCalledWith('DevToolsManager: error in registerDevTools:', expect.any(Error));
});
test('should log errors without throwing in cleanupDevTools', async () => {
const mockGuest = {
devToolsWebContents: {
id: 67890,
isDestroyed: vi.fn().mockReturnValue(false),
},
};
mockWebContents.fromId.mockReturnValue(mockGuest as unknown as WebContents);
await devToolsManager.registerDevTools(12345);
mockWebContents.fromId.mockImplementation(() => {
throw new Error('Cleanup error');
});
await expect(devToolsManager.cleanupDevTools(12345)).resolves.not.toThrow();
expect(console.error).toHaveBeenCalledWith('DevToolsManager: error closing DevTools window:', expect.any(Error));
});
test('should continue execution after individual strategy failures', async () => {
const mockGuest = {
devToolsWebContents: {
id: 67890,
isDestroyed: vi.fn().mockReturnValue(false),
},
};
mockWebContents.fromId.mockReturnValue(mockGuest as unknown as WebContents);
await devToolsManager.registerDevTools(12345);
mockWebContents.fromId.mockReturnValue(undefined); // Strategy 1 & 2 fail
const mockWindow = {
isDestroyed: vi.fn().mockReturnValue(false),
webContents: { id: 67890 },
close: vi.fn(),
};
mockBrowserWindow.getAllWindows.mockReturnValue([mockWindow as unknown as BrowserWindow]);
await devToolsManager.cleanupDevTools(12345);
expect(mockWindow.close).toHaveBeenCalled();
expect(devToolsManager.getTrackedDevToolsCount()).toBe(0);
});
});
describe('Helper Methods', () => {
test('tryCloseWebContents should call close() method when available', async () => {
const mockDevToolsWebContents = {
close: vi.fn(),
};
const result = await (devToolsManager as unknown as Record<string, (...args: unknown[]) => Promise<boolean>>)[
'tryCloseWebContents'
]!(mockDevToolsWebContents);
expect(result).toBe(true);
expect(mockDevToolsWebContents.close).toHaveBeenCalled();
});
test('tryCloseWebContents should handle missing close() method', async () => {
const webContentsWithoutClose = {};
const result = await (devToolsManager as unknown as Record<string, (...args: unknown[]) => Promise<boolean>>)[
'tryCloseWebContents'
]!(webContentsWithoutClose);
expect(result).toBe(true); // Still returns true as close() is optional
});
test('tryCloseWebContents should handle close() method errors', async () => {
const mockDevToolsWebContents = {
close: vi.fn().mockImplementation(() => {
throw new Error('Close method failed');
}),
};
const result = await (devToolsManager as unknown as Record<string, (...args: unknown[]) => Promise<boolean>>)[
'tryCloseWebContents'
]!(mockDevToolsWebContents);
expect(result).toBe(false);
});
test('tryDestroyWebContents should call destroy() method when available', async () => {
const mockDevToolsWebContents = {
destroy: vi.fn(),
};
const result = await (devToolsManager as unknown as Record<string, (...args: unknown[]) => Promise<boolean>>)[
'tryDestroyWebContents'
]!(mockDevToolsWebContents);
expect(result).toBe(true);
expect(mockDevToolsWebContents.destroy).toHaveBeenCalled();
});
test('tryDestroyWebContents should handle missing destroy() method', async () => {
const webContentsWithoutDestroy = {};
const result = await (devToolsManager as unknown as Record<string, (...args: unknown[]) => Promise<boolean>>)[
'tryDestroyWebContents'
]!(webContentsWithoutDestroy);
expect(result).toBe(true); // Still returns true as destroy() is optional
});
test('tryDestroyWebContents should handle destroy() method errors', async () => {
const mockDevToolsWebContents = {
destroy: vi.fn().mockImplementation(() => {
throw new Error('Destroy method failed');
}),
};
const result = await (devToolsManager as unknown as Record<string, (...args: unknown[]) => Promise<boolean>>)[
'tryDestroyWebContents'
]!(mockDevToolsWebContents);
expect(result).toBe(false);
});
});
describe('Integration Scenarios', () => {
test('should handle complete workflow: register -> cleanup', async () => {
const mockGuest = {
devToolsWebContents: {
id: 67890,
isDestroyed: vi.fn().mockReturnValue(false),
},
};
mockWebContents.fromId.mockReturnValue(mockGuest as unknown as WebContents);
await devToolsManager.registerDevTools(12345);
expect(devToolsManager.getTrackedDevToolsCount()).toBe(1);
const mockGuestForCleanup = {
isDestroyed: vi.fn().mockReturnValue(false),
isDevToolsOpened: vi.fn().mockReturnValue(true),
closeDevTools: vi.fn(),
};
mockWebContents.fromId.mockReturnValue(mockGuestForCleanup as unknown as WebContents);
await devToolsManager.cleanupDevTools(12345);
expect(devToolsManager.getTrackedDevToolsCount()).toBe(0);
expect(mockGuestForCleanup.closeDevTools).toHaveBeenCalled();
});
test('should handle multiple concurrent registrations', async () => {
const mockGuest1 = {
devToolsWebContents: {
id: 55555,
isDestroyed: vi.fn().mockReturnValue(false),
},
};
const mockGuest2 = {
devToolsWebContents: {
id: 66666,
isDestroyed: vi.fn().mockReturnValue(false),
},
};
mockWebContents.fromId
.mockReturnValueOnce(mockGuest1 as unknown as WebContents)
.mockReturnValueOnce(mockGuest2 as unknown as WebContents);
await Promise.all([devToolsManager.registerDevTools(11111), devToolsManager.registerDevTools(22222)]);
expect(devToolsManager.getTrackedDevToolsCount()).toBe(2);
});
test('should handle rapid register/cleanup cycles', async () => {
for (let i = 0; i < 5; i++) {
const mockGuest = {
devToolsWebContents: {
id: 67890 + i,
isDestroyed: vi.fn().mockReturnValue(false),
},
};
mockWebContents.fromId.mockReturnValue(mockGuest as unknown as WebContents);
await devToolsManager.registerDevTools(12345 + i);
const mockGuestForCleanup = {
isDestroyed: vi.fn().mockReturnValue(false),
isDevToolsOpened: vi.fn().mockReturnValue(true),
closeDevTools: vi.fn(),
};
mockWebContents.fromId.mockReturnValue(mockGuestForCleanup as unknown as WebContents);
await devToolsManager.cleanupDevTools(12345 + i);
}
expect(devToolsManager.getTrackedDevToolsCount()).toBe(0);
});
test('should handle mixed success/failure scenarios', async () => {
const mockGuest = {
devToolsWebContents: {
id: 67890,
isDestroyed: vi.fn().mockReturnValue(false),
},
};
mockWebContents.fromId
.mockReturnValueOnce(mockGuest as unknown as WebContents) // Success
.mockReturnValueOnce(undefined) // Fail
.mockReturnValueOnce(mockGuest as unknown as WebContents); // Success
await devToolsManager.registerDevTools(11111);
await devToolsManager.registerDevTools(22222);
await devToolsManager.registerDevTools(33333);
expect(devToolsManager.getTrackedDevToolsCount()).toBe(2);
const mockGuestForCleanup = {
isDestroyed: vi.fn().mockReturnValue(false),
isDevToolsOpened: vi.fn().mockReturnValue(true),
closeDevTools: vi.fn(),
};
const mockWindow = {
isDestroyed: vi.fn().mockReturnValue(false),
webContents: { id: 67890 },
close: vi.fn(),
};
mockWebContents.fromId
.mockReturnValueOnce(mockGuestForCleanup as unknown as WebContents) // Strategy 1 succeeds
.mockReturnValueOnce(undefined); // Strategy 1 fails
mockBrowserWindow.getAllWindows.mockReturnValue([mockWindow as unknown as BrowserWindow]); // Strategy 3 succeeds
await devToolsManager.cleanupDevTools(11111);
await devToolsManager.cleanupDevTools(33333);
expect(devToolsManager.getTrackedDevToolsCount()).toBe(0);
});
});
describe('Edge Cases', () => {
test('should handle DevTools WebContents with id 0', async () => {
const mockGuest = {
devToolsWebContents: {
id: 0,
isDestroyed: vi.fn().mockReturnValue(false),
},
};
mockWebContents.fromId.mockReturnValue(mockGuest as unknown as WebContents);
await devToolsManager.registerDevTools(12345);
expect(devToolsManager.getTrackedDevToolsCount()).toBe(1);
});
test('should handle negative webContents IDs', async () => {
mockWebContents.fromId.mockReturnValue(undefined);
await devToolsManager.registerDevTools(-1);
await devToolsManager.cleanupDevTools(-1);
expect(devToolsManager.getTrackedDevToolsCount()).toBe(0);
});
test('should handle cleanup of non-existent entries multiple times', async () => {
await devToolsManager.cleanupDevTools(99999);
await devToolsManager.cleanupDevTools(99999);
await devToolsManager.cleanupDevTools(99999);
expect(devToolsManager.getTrackedDevToolsCount()).toBe(0);
});
test('should handle registration with duplicate IDs', async () => {
const mockGuest = {
devToolsWebContents: {
id: 67890,
isDestroyed: vi.fn().mockReturnValue(false),
},
};
mockWebContents.fromId.mockReturnValue(mockGuest as unknown as WebContents);
await devToolsManager.registerDevTools(12345);
await devToolsManager.registerDevTools(12345);
await devToolsManager.registerDevTools(12345);
expect(devToolsManager.getTrackedDevToolsCount()).toBe(1);
});
});
});

View file

@ -0,0 +1,177 @@
/**********************************************************************
* Copyright (C) 2025 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import { BrowserWindow, type WebContents, webContents } from 'electron';
import { injectable } from 'inversify';
/**
* Service responsible for managing DevTools lifecycle for WebViews.
* Handles tracking, opening, and cleanup of DevTools to prevent application crashes
* when WebViews are destroyed while DevTools are still open.
*/
@injectable()
export class DevToolsManager {
private webviewDevToolsMap = new Map<number, number>();
/**
* Register and track DevTools for a WebView.
* Should be called when DevTools are opened for a WebView.
*/
async registerDevTools(webcontentId: number): Promise<void> {
try {
const guest = webContents.fromId(webcontentId);
if (!guest) return;
// Access undocumented devToolsWebContents property
const devToolsWebContents = (guest as WebContents & { devToolsWebContents?: WebContents }).devToolsWebContents;
if (devToolsWebContents && !devToolsWebContents.isDestroyed()) {
const devToolsId = devToolsWebContents.id;
this.webviewDevToolsMap.set(webcontentId, devToolsId);
}
} catch (error) {
console.error('DevToolsManager: error in registerDevTools:', error);
}
}
/**
* Clean up DevTools associated with a WebView.
* Should be called when a WebView is being destroyed to prevent crashes.
*/
async cleanupDevTools(webcontentId: number): Promise<void> {
try {
const devToolsId = this.webviewDevToolsMap.get(webcontentId);
if (!devToolsId) {
return;
}
try {
const closed =
(await this.tryCloseDevToolsViaWebContents(webcontentId)) ||
(await this.tryCloseDevToolsDirectly(devToolsId)) ||
(await this.tryCloseDevToolsViaWindow(devToolsId));
if (!closed) {
console.warn('DevToolsManager: failed to close DevTools using all available methods');
}
} catch (closeError) {
console.error('DevToolsManager: error closing DevTools window:', closeError);
} finally {
this.webviewDevToolsMap.delete(webcontentId);
}
} catch (error) {
console.error('DevToolsManager: error in cleanupDevTools:', error);
}
}
/**
* Get the number of tracked DevTools instances.
* Useful for debugging and monitoring.
*/
getTrackedDevToolsCount(): number {
return this.webviewDevToolsMap.size;
}
/**
* Clear all tracked DevTools mappings.
* Useful for cleanup during application shutdown.
*/
clearAllTracking(): void {
this.webviewDevToolsMap.clear();
}
/**
* Strategy 1: Try to close DevTools using the original WebContents API.
* This is the preferred method when the original WebView still exists.
*/
private async tryCloseDevToolsViaWebContents(webcontentId: number): Promise<boolean> {
const originalWebContents = webContents.fromId(webcontentId);
if (originalWebContents && !originalWebContents.isDestroyed() && originalWebContents.isDevToolsOpened()) {
originalWebContents.closeDevTools();
return true;
}
return false;
}
/**
* Strategy 2: Try to close DevTools directly via DevTools WebContents.
* Used when the original WebView is already destroyed.
*/
private async tryCloseDevToolsDirectly(devToolsId: number): Promise<boolean> {
const devToolsWebContents = webContents.fromId(devToolsId);
if (!devToolsWebContents || devToolsWebContents.isDestroyed()) {
return false;
}
// Try close() first
if (await this.tryCloseWebContents(devToolsWebContents)) {
return true;
}
// Try destroy() as fallback
return await this.tryDestroyWebContents(devToolsWebContents);
}
/**
* Strategy 3: Try to find and close the DevTools window via BrowserWindow.
* Last resort method when other strategies fail.
*/
private async tryCloseDevToolsViaWindow(devToolsId: number): Promise<boolean> {
const allWindows = BrowserWindow.getAllWindows();
for (const win of allWindows) {
if (win.isDestroyed()) {
continue;
}
const winWebContentsId = win.webContents.id;
if (winWebContentsId === devToolsId) {
win.close();
return true;
}
}
return false;
}
/**
* Helper method to close WebContents using undocumented close() method.
*/
private async tryCloseWebContents(webContents: WebContents & { close?: () => void }): Promise<boolean> {
try {
// Call undocumented close() method if available
webContents.close?.();
return true;
} catch (error) {
return false;
}
}
/**
* Helper method to destroy WebContents using undocumented destroy() method.
*/
private async tryDestroyWebContents(webContents: WebContents & { destroy?: () => void }): Promise<boolean> {
try {
// Call undocumented destroy() method if available
webContents.destroy?.();
return true;
} catch (error) {
return false;
}
}
}

View file

@ -26,6 +26,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { ApiSenderType } from '/@/plugin/api.js';
import type { DevToolsManager } from './devtools-manager.js';
import type { WebviewPanelImpl } from './webview-panel-impl.js';
import { WebviewRegistry } from './webview-registry.js';
@ -67,6 +68,11 @@ const apiSender: ApiSenderType = {
receive: vi.fn(),
};
const mockDevToolsManager = {
registerDevTools: vi.fn(),
cleanupDevTools: vi.fn(),
} as unknown as DevToolsManager;
const getRouterMock = vi.fn();
const fakeRouter = {
get: getRouterMock,
@ -78,7 +84,7 @@ const currentConsoleLog = console.log;
beforeEach(() => {
vi.resetAllMocks();
console.log = vi.fn();
webviewRegistry = new TestWebviewRegistry(apiSender);
webviewRegistry = new TestWebviewRegistry(apiSender, mockDevToolsManager);
// mock buildRouter method
spyRouter = vi.spyOn(webviewRegistry, 'buildRouter').mockReturnValue(fakeRouter);

View file

@ -30,6 +30,7 @@ import { Uri } from '/@/plugin/types/uri.js';
import type { WebviewInfo, WebviewSimpleInfo } from '/@api/webview-info.js';
import { getFreePort } from '../util/port.js';
import { DevToolsManager } from './devtools-manager.js';
import { WebviewImpl } from './webview-impl.js';
import { WebviewPanelImpl } from './webview-panel-impl.js';
@ -93,7 +94,10 @@ export class WebviewRegistry {
#app: Application;
constructor(@inject(ApiSenderType) apiSender: ApiSenderType) {
constructor(
@inject(ApiSenderType) apiSender: ApiSenderType,
@inject(DevToolsManager) private devToolsManager: DevToolsManager,
) {
this.#apiSender = apiSender;
this.#webviews = new Map();
this.#uuidAndPaths = new Map();
@ -287,6 +291,22 @@ export class WebviewRegistry {
return this.#serverPort;
}
/**
* Register DevTools for a WebView when they are opened.
* This method is called via IPC from the renderer process.
*/
async registerWebviewDevTools(webcontentId: number): Promise<void> {
return this.devToolsManager.registerDevTools(webcontentId);
}
/**
* Clean up DevTools when a WebView is being destroyed.
* This method is called via IPC from the renderer process.
*/
async cleanupWebviewDevTools(webcontentId: number): Promise<void> {
return this.devToolsManager.cleanupDevTools(webcontentId);
}
listWebviews(): WebviewInfo[] {
return Array.from(this.#webviews.entries()).map(entry => {
const id = entry[0];

View file

@ -2438,6 +2438,14 @@ export function initExposure(): void {
return ipcInvoke('webviewRegistry:makeDefaultWebviewVisible', webviewId);
});
contextBridge.exposeInMainWorld('registerWebviewDevTools', async (webcontentId: number): Promise<void> => {
return ipcInvoke('webview:devtools:register', webcontentId);
});
contextBridge.exposeInMainWorld('cleanupWebviewDevTools', async (webcontentId: number): Promise<void> => {
return ipcInvoke('webview:devtools:cleanup', webcontentId);
});
contextBridge.exposeInMainWorld(
'fetchExtensionViewsContributions',
async (extensionId: string): Promise<ViewInfoUI[]> => {

View file

@ -6,6 +6,7 @@ import { webviews } from '/@/stores/webviews';
import type { WebviewInfo } from '/@api/webview-info';
import Route from '../../Route.svelte';
import { webviewLifecycle } from './webview-directive';
// webview id
export let id: string;
@ -33,6 +34,9 @@ $: webviewInfo && notifyNewWebwievState();
// webview HTML element used to communicate
let webviewElement: HTMLElement | undefined;
// reactive options for webview lifecycle directive - updates when webviewInfo changes
$: lifecycleOptions = { webviewInfo };
// function to notify webview when messages are coming
const postMessageToWebview = (webviewEvent: unknown): void => {
const webviewEventTyped = webviewEvent as { id: string; message: unknown };
@ -102,6 +106,7 @@ onDestroy(() => {
<Route path="/*" breadcrumb={webviewInfo.name}>
<webview
bind:this={webviewElement}
use:webviewLifecycle={lifecycleOptions}
aria-label="Webview {webviewInfo?.name}"
role="document"
httpreferrer="http://{webviewInfo?.uuid}.webview.localhost:{webViewPort}"

View file

@ -0,0 +1,376 @@
/**********************************************************************
* Copyright (C) 2025 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import type { WebviewInfo } from '/@api/webview-info';
import {
createWindowIpcApi,
type WebviewDirectiveOptions,
webviewLifecycle,
type WebviewLifecycleDependencies,
webviewLifecycleInternal,
} from './webview-directive';
import type { IpcApi, WebviewElement } from './webview-lifecycle-manager';
import { WebviewLifecycleManager } from './webview-lifecycle-manager';
describe('webview-directive', () => {
let mockWebviewElement: WebviewElement;
let mockIpcApi: IpcApi;
let mockWebviewInfo: WebviewInfo;
let mockLifecycleManager: WebviewLifecycleManager;
beforeEach(() => {
vi.clearAllMocks();
mockWebviewElement = {
getWebContentsId: vi.fn().mockReturnValue(12345),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as WebviewElement;
mockIpcApi = {
registerWebviewDevTools: vi.fn().mockResolvedValue(undefined),
cleanupWebviewDevTools: vi.fn().mockResolvedValue(undefined),
};
mockWebviewInfo = {
id: 'test-webview-id',
name: 'Test Webview',
uuid: 'test-uuid',
} as WebviewInfo;
mockLifecycleManager = {
handleDomReady: vi.fn(),
handleDevToolsOpened: vi.fn(),
updateWebviewInfo: vi.fn(),
cleanup: vi.fn(),
getWebContentsId: vi.fn(),
getWebviewInfo: vi.fn(),
} as unknown as WebviewLifecycleManager;
delete (window as unknown as Record<string, unknown>).registerWebviewDevTools;
delete (window as unknown as Record<string, unknown>).cleanupWebviewDevTools;
});
describe('createWindowIpcApi', () => {
test('should create IPC API from window globals when available', () => {
(window as unknown as Record<string, unknown>).registerWebviewDevTools = mockIpcApi.registerWebviewDevTools;
(window as unknown as Record<string, unknown>).cleanupWebviewDevTools = mockIpcApi.cleanupWebviewDevTools;
const result = createWindowIpcApi();
expect(result).toEqual({
registerWebviewDevTools: mockIpcApi.registerWebviewDevTools,
cleanupWebviewDevTools: mockIpcApi.cleanupWebviewDevTools,
});
});
test('should throw error when registerWebviewDevTools is not available', () => {
(window as unknown as Record<string, unknown>).cleanupWebviewDevTools = mockIpcApi.cleanupWebviewDevTools;
expect(() => createWindowIpcApi()).toThrow(
'Required webview DevTools management functions are not available on window',
);
});
test('should throw error when cleanupWebviewDevTools is not available', () => {
(window as unknown as Record<string, unknown>).registerWebviewDevTools = mockIpcApi.registerWebviewDevTools;
expect(() => createWindowIpcApi()).toThrow(
'Required webview DevTools management functions are not available on window',
);
});
test('should throw error when both functions are missing', () => {
expect(() => createWindowIpcApi()).toThrow(
'Required webview DevTools management functions are not available on window',
);
});
});
describe('webviewLifecycle', () => {
test('should create event listeners for dom-ready and devtools-opened', () => {
const options: WebviewDirectiveOptions = {
webviewInfo: mockWebviewInfo,
ipcApi: mockIpcApi,
};
webviewLifecycle(mockWebviewElement, options);
expect(mockWebviewElement.addEventListener).toHaveBeenCalledWith('dom-ready', expect.any(Function));
expect(mockWebviewElement.addEventListener).toHaveBeenCalledWith('devtools-opened', expect.any(Function));
expect(mockWebviewElement.addEventListener).toHaveBeenCalledTimes(2);
});
test('should return object with update and destroy methods', () => {
const options: WebviewDirectiveOptions = {
webviewInfo: mockWebviewInfo,
ipcApi: mockIpcApi,
};
const result = webviewLifecycle(mockWebviewElement, options);
expect(result).toHaveProperty('update');
expect(result).toHaveProperty('destroy');
expect(typeof result.update).toBe('function');
expect(typeof result.destroy).toBe('function');
});
test('should use provided ipcApi when available', () => {
const options: WebviewDirectiveOptions = {
webviewInfo: mockWebviewInfo,
ipcApi: mockIpcApi,
};
webviewLifecycle(mockWebviewElement, options);
expect(mockWebviewElement.addEventListener).toHaveBeenCalled();
});
test('should fallback to window globals when ipcApi not provided', () => {
(window as unknown as Record<string, unknown>).registerWebviewDevTools = mockIpcApi.registerWebviewDevTools;
(window as unknown as Record<string, unknown>).cleanupWebviewDevTools = mockIpcApi.cleanupWebviewDevTools;
const options: WebviewDirectiveOptions = {
webviewInfo: mockWebviewInfo,
};
const result = webviewLifecycle(mockWebviewElement, options);
expect(result).toBeDefined();
expect(mockWebviewElement.addEventListener).toHaveBeenCalled();
});
test('should work with minimal options', () => {
const options: WebviewDirectiveOptions = {
ipcApi: mockIpcApi,
};
const result = webviewLifecycle(mockWebviewElement, options);
expect(result).toBeDefined();
expect(mockWebviewElement.addEventListener).toHaveBeenCalled();
});
test('should work with empty options when window globals are available', () => {
(window as unknown as Record<string, unknown>).registerWebviewDevTools = mockIpcApi.registerWebviewDevTools;
(window as unknown as Record<string, unknown>).cleanupWebviewDevTools = mockIpcApi.cleanupWebviewDevTools;
const result = webviewLifecycle(mockWebviewElement);
expect(result).toBeDefined();
expect(mockWebviewElement.addEventListener).toHaveBeenCalled();
});
});
describe('directive return object behavior', () => {
let directiveResult: ReturnType<typeof webviewLifecycle>;
beforeEach(() => {
const options: WebviewDirectiveOptions = {
webviewInfo: mockWebviewInfo,
ipcApi: mockIpcApi,
};
directiveResult = webviewLifecycle(mockWebviewElement, options);
});
test('update method should call manager.updateWebviewInfo', () => {
const mockManager = vi.mocked(WebviewLifecycleManager);
vi.mocked(mockManager.prototype.updateWebviewInfo);
const newWebviewInfo = { ...mockWebviewInfo, id: 'new-id' };
directiveResult.update({ webviewInfo: newWebviewInfo });
expect(directiveResult.update).toBeDefined();
});
test('destroy method should remove event listeners and call cleanup', () => {
directiveResult.destroy();
expect(mockWebviewElement.removeEventListener).toHaveBeenCalledWith('dom-ready', expect.any(Function));
expect(mockWebviewElement.removeEventListener).toHaveBeenCalledWith('devtools-opened', expect.any(Function));
expect(mockWebviewElement.removeEventListener).toHaveBeenCalledTimes(2);
});
});
describe('error handling', () => {
test('should throw error when window globals missing and no ipcApi provided', () => {
expect(() => webviewLifecycle(mockWebviewElement)).toThrow(
'Required webview DevTools management functions are not available on window',
);
});
test('should handle partial window globals gracefully', () => {
(window as unknown as Record<string, unknown>).registerWebviewDevTools = mockIpcApi.registerWebviewDevTools;
expect(() => webviewLifecycle(mockWebviewElement)).toThrow(
'Required webview DevTools management functions are not available on window',
);
});
});
describe('webviewLifecycleInternal with dependency injection', () => {
test('should use provided ipcApi from dependencies', () => {
const dependencies: WebviewLifecycleDependencies = {
ipcApi: mockIpcApi,
};
const result = webviewLifecycleInternal(mockWebviewElement, {}, dependencies);
expect(result).toBeDefined();
expect(mockWebviewElement.addEventListener).toHaveBeenCalled();
});
test('should use custom manager factory', () => {
const mockCreateManager = vi.fn().mockReturnValue(mockLifecycleManager);
const dependencies: WebviewLifecycleDependencies = {
ipcApi: mockIpcApi,
createManager: mockCreateManager,
};
webviewLifecycleInternal(mockWebviewElement, { webviewInfo: mockWebviewInfo }, dependencies);
expect(mockCreateManager).toHaveBeenCalledWith(mockIpcApi, mockWebviewInfo);
});
test('should use custom ipcApi factory', () => {
const mockCreateIpcApi = vi.fn().mockReturnValue(mockIpcApi);
const dependencies: WebviewLifecycleDependencies = {
createIpcApi: mockCreateIpcApi,
};
webviewLifecycleInternal(mockWebviewElement, {}, dependencies);
expect(mockCreateIpcApi).toHaveBeenCalled();
});
test('should prioritize options.ipcApi over dependencies.ipcApi', () => {
const optionsIpcApi = { ...mockIpcApi };
const dependenciesIpcApi = { ...mockIpcApi };
const mockCreateManager = vi.fn().mockReturnValue(mockLifecycleManager);
const dependencies: WebviewLifecycleDependencies = {
ipcApi: dependenciesIpcApi,
createManager: mockCreateManager,
};
webviewLifecycleInternal(mockWebviewElement, { ipcApi: optionsIpcApi }, dependencies);
expect(mockCreateManager).toHaveBeenCalledWith(optionsIpcApi, undefined);
});
test('should call manager methods through event handlers', () => {
const dependencies: WebviewLifecycleDependencies = {
ipcApi: mockIpcApi,
createManager: vi.fn().mockReturnValue(mockLifecycleManager),
};
webviewLifecycleInternal(mockWebviewElement, {}, dependencies);
const addEventListener = vi.mocked(mockWebviewElement.addEventListener);
const domReadyHandler = addEventListener.mock.calls.find(call => call[0] === 'dom-ready')?.[1];
const devtoolsOpenedHandler = addEventListener.mock.calls.find(call => call[0] === 'devtools-opened')?.[1];
expect(domReadyHandler).toBeDefined();
expect(devtoolsOpenedHandler).toBeDefined();
if (domReadyHandler && typeof domReadyHandler === 'function') {
domReadyHandler(new Event('dom-ready'));
expect(mockLifecycleManager.handleDomReady).toHaveBeenCalledWith(mockWebviewElement);
}
if (devtoolsOpenedHandler && typeof devtoolsOpenedHandler === 'function') {
devtoolsOpenedHandler(new Event('devtools-opened'));
expect(mockLifecycleManager.handleDevToolsOpened).toHaveBeenCalled();
}
});
test('should update webview info when update is called', () => {
const dependencies: WebviewLifecycleDependencies = {
ipcApi: mockIpcApi,
createManager: vi.fn().mockReturnValue(mockLifecycleManager),
};
const result = webviewLifecycleInternal(mockWebviewElement, {}, dependencies);
const newWebviewInfo = { ...mockWebviewInfo, id: 'updated-id' };
result.update({ webviewInfo: newWebviewInfo });
expect(mockLifecycleManager.updateWebviewInfo).toHaveBeenCalledWith(newWebviewInfo);
});
test('should cleanup manager and remove listeners when destroy is called', () => {
const dependencies: WebviewLifecycleDependencies = {
ipcApi: mockIpcApi,
createManager: vi.fn().mockReturnValue(mockLifecycleManager),
};
const result = webviewLifecycleInternal(mockWebviewElement, {}, dependencies);
result.destroy();
expect(mockLifecycleManager.cleanup).toHaveBeenCalled();
expect(mockWebviewElement.removeEventListener).toHaveBeenCalledWith('dom-ready', expect.any(Function));
expect(mockWebviewElement.removeEventListener).toHaveBeenCalledWith('devtools-opened', expect.any(Function));
});
});
describe('edge cases and integration', () => {
test('should handle undefined webviewInfo in options', () => {
const dependencies: WebviewLifecycleDependencies = {
ipcApi: mockIpcApi,
createManager: vi.fn().mockReturnValue(mockLifecycleManager),
};
const result = webviewLifecycleInternal(mockWebviewElement, { webviewInfo: undefined }, dependencies);
expect(result).toBeDefined();
});
test('should handle empty dependencies object', () => {
(window as unknown as Record<string, unknown>).registerWebviewDevTools = mockIpcApi.registerWebviewDevTools;
(window as unknown as Record<string, unknown>).cleanupWebviewDevTools = mockIpcApi.cleanupWebviewDevTools;
const result = webviewLifecycleInternal(mockWebviewElement, {}, {});
expect(result).toBeDefined();
expect(mockWebviewElement.addEventListener).toHaveBeenCalled();
});
test('should handle multiple directive instances on different elements', () => {
const mockWebviewElement2 = {
...mockWebviewElement,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as WebviewElement;
const result1 = webviewLifecycle(mockWebviewElement, { ipcApi: mockIpcApi });
const result2 = webviewLifecycle(mockWebviewElement2, { ipcApi: mockIpcApi });
expect(result1).toBeDefined();
expect(result2).toBeDefined();
expect(result1).not.toBe(result2);
expect(mockWebviewElement.addEventListener).toHaveBeenCalled();
expect(mockWebviewElement2.addEventListener).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,216 @@
/**********************************************************************
* Copyright (C) 2025 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import type { WebviewInfo } from '/@api/webview-info';
import { type IpcApi, type WebviewElement, WebviewLifecycleManager } from './webview-lifecycle-manager';
/**
* Configuration options for the webview lifecycle directive
*/
export interface WebviewDirectiveOptions {
/**
* Information about the webview being managed
* @optional Used for webview identification and cleanup logic
*/
webviewInfo?: WebviewInfo;
/**
* Custom IPC API for communicating with the main process
* @optional If not provided, will use window globals from preload script
*/
ipcApi?: IpcApi;
}
/**
* Return type for Svelte action/directive lifecycle methods
*/
export interface WebviewDirectiveReturn {
/**
* Called when the directive's options change
* @param newOptions - Updated configuration options
*/
update(newOptions: WebviewDirectiveOptions): void;
/**
* Called when the element is removed from the DOM
* Performs cleanup including event listener removal and resource cleanup
*/
destroy(): void;
}
/**
* Safely creates an IPC API from window globals with proper error handling.
*
* This function attempts to create an IPC API object by reading the webview DevTools
* management functions from the global window object. These functions should be
* exposed by the preload script during application initialization.
*
* @returns An IPC API object with registerWebviewDevTools and cleanupWebviewDevTools methods
* @throws {Error} When required window globals are not available or undefined
*
* @example
* ```typescript
* try {
* const ipcApi = createWindowIpcApi();
* // Use ipcApi for webview DevTools management
* } catch (error) {
* console.error('Failed to create IPC API:', error);
* // Handle the error appropriately
* }
* ```
*/
export function createWindowIpcApi(): IpcApi {
if (!window.registerWebviewDevTools || !window.cleanupWebviewDevTools) {
throw new Error(
'Required webview DevTools management functions are not available on window. ' +
'Ensure the preload script has properly exposed registerWebviewDevTools and cleanupWebviewDevTools.',
);
}
return {
registerWebviewDevTools: window.registerWebviewDevTools,
cleanupWebviewDevTools: window.cleanupWebviewDevTools,
};
}
/**
* Dependencies for webview lifecycle management.
*
* This interface enables dependency injection for testing purposes and custom implementations.
* All dependencies are optional and have sensible defaults for production use.
*
* @example
* ```typescript
* // For testing with mocked dependencies
* const mockDependencies: WebviewLifecycleDependencies = {
* ipcApi: mockIpcApi,
* createManager: vi.fn().mockReturnValue(mockManager),
* createIpcApi: vi.fn().mockReturnValue(mockIpcApi),
* };
*
* webviewLifecycleInternal(mockElement, options, mockDependencies);
* ```
*/
export interface WebviewLifecycleDependencies {
/**
* IPC API for communicating with the main process.
*
* When provided, this IPC API will be used directly instead of creating one
* from window globals or the options parameter.
*
* @optional Priority: options.ipcApi > dependencies.ipcApi > createIpcApi()
*/
ipcApi?: IpcApi;
/**
* Factory function for creating WebviewLifecycleManager instances.
*
* Allows injection of custom or mocked lifecycle managers for testing.
* The factory receives the resolved IPC API and webview info.
*
* @param ipcApi - The resolved IPC API to pass to the manager
* @param webviewInfo - Optional webview information for identification
* @returns A WebviewLifecycleManager instance or compatible object
* @default WebviewLifecycleManager constructor
*/
createManager?: (ipcApi: IpcApi, webviewInfo?: WebviewInfo) => WebviewLifecycleManager;
/**
* Factory function for creating IPC API from window globals.
*
* Used as fallback when no IPC API is provided via options or dependencies.
* Useful for testing scenarios where window globals need to be mocked.
*
* @returns An IPC API object compatible with the IpcApi interface
* @throws {Error} When required window globals are not available
* @default createWindowIpcApi
*/
createIpcApi?: () => IpcApi;
}
/**
* Internal implementation of webview lifecycle directive with dependency injection.
* Exported for testing purposes.
*
* @param node - The HTML element (webview) to attach the directive to
* @param options - Configuration options for the directive
* @param dependencies - Injectable dependencies for testing
*/
export function webviewLifecycleInternal(
node: HTMLElement,
options: WebviewDirectiveOptions = {},
dependencies: WebviewLifecycleDependencies = {},
): WebviewDirectiveReturn {
const webview = node as unknown as WebviewElement;
const {
createIpcApi = createWindowIpcApi,
createManager = (ipcApi: IpcApi, webviewInfo?: WebviewInfo): WebviewLifecycleManager =>
new WebviewLifecycleManager(ipcApi, webviewInfo),
} = dependencies;
const ipcApi: IpcApi = options.ipcApi ?? dependencies.ipcApi ?? createIpcApi();
const manager = createManager(ipcApi, options.webviewInfo);
const domReadyHandler = (_event: Event): void => manager.handleDomReady(webview);
const devtoolsOpenedHandler = (_event: Event): void => manager.handleDevToolsOpened();
webview.addEventListener('dom-ready', domReadyHandler);
webview.addEventListener('devtools-opened', devtoolsOpenedHandler);
return {
/**
* Called when the directive's options change
*/
update(newOptions: WebviewDirectiveOptions): void {
manager.updateWebviewInfo(newOptions.webviewInfo);
},
/**
* Called when the element is removed from the DOM
*/
destroy(): void {
manager.cleanup();
webview.removeEventListener('dom-ready', domReadyHandler);
webview.removeEventListener('devtools-opened', devtoolsOpenedHandler);
},
};
}
/**
* Svelte action/directive that manages webview lifecycle events.
* Handles DevTools registration and cleanup to prevent application crashes.
*
* @param node - The HTML element (webview) to attach the directive to
* @param options - Configuration options for the directive
* @returns Object with update and destroy methods for Svelte action lifecycle
*
* @example
* ```svelte
* <webview use:webviewLifecycle={{ webviewInfo }} />
* ```
*
* @example
* ```svelte
* <webview use:webviewLifecycle={{ webviewInfo, ipcApi: customIpcApi }} />
* ```
*/
export function webviewLifecycle(node: HTMLElement, options: WebviewDirectiveOptions = {}): WebviewDirectiveReturn {
return webviewLifecycleInternal(node, options);
}

View file

@ -0,0 +1,182 @@
/**********************************************************************
* Copyright (C) 2025 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import type { WebviewInfo } from '/@api/webview-info';
import type { IpcApi, WebviewElement } from './webview-lifecycle-manager';
import { WebviewLifecycleManager } from './webview-lifecycle-manager';
describe('WebviewLifecycleManager', () => {
let manager: WebviewLifecycleManager;
let mockIpcApi: IpcApi;
let mockWebview: WebviewElement;
let mockWebviewInfo: WebviewInfo;
beforeEach(() => {
mockIpcApi = {
registerWebviewDevTools: vi.fn().mockResolvedValue(undefined),
cleanupWebviewDevTools: vi.fn().mockResolvedValue(undefined),
};
mockWebview = {
getWebContentsId: vi.fn().mockReturnValue(12345),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as WebviewElement;
mockWebviewInfo = {
id: 'test-webview-id',
name: 'Test Webview',
uuid: 'test-uuid',
} as WebviewInfo;
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
manager = new WebviewLifecycleManager(mockIpcApi, mockWebviewInfo);
});
describe('constructor', () => {
test('should initialize with provided webview info', () => {
expect(manager.getWebviewInfo()).toBe(mockWebviewInfo);
});
test('should initialize without webview info', () => {
const managerWithoutInfo = new WebviewLifecycleManager(mockIpcApi);
expect(managerWithoutInfo.getWebviewInfo()).toBeUndefined();
});
});
describe('updateWebviewInfo', () => {
test('should update webview info', () => {
const newWebviewInfo = { ...mockWebviewInfo, id: 'new-id' };
manager.updateWebviewInfo(newWebviewInfo);
expect(manager.getWebviewInfo()).toBe(newWebviewInfo);
});
test('should set webview info to undefined', () => {
manager.updateWebviewInfo();
expect(manager.getWebviewInfo()).toBeUndefined();
});
});
describe('handleDomReady', () => {
test('should extract and store webContentsId', () => {
manager.handleDomReady(mockWebview);
expect(mockWebview.getWebContentsId).toHaveBeenCalled();
expect(manager.getWebContentsId()).toBe(12345);
});
test('should handle error when getting webContentsId fails', () => {
const error = new Error('Failed to get webContentsId');
vi.mocked(mockWebview.getWebContentsId).mockImplementation(() => {
throw error;
});
manager.handleDomReady(mockWebview);
expect(manager.getWebContentsId()).toBeUndefined();
expect(console.error).toHaveBeenCalledWith('Failed to get webview webContentsId:', error);
});
});
describe('handleDevToolsOpened', () => {
test('should call registerWebviewDevTools when webContentsId is available', () => {
manager.handleDomReady(mockWebview);
manager.handleDevToolsOpened();
expect(mockIpcApi.registerWebviewDevTools).toHaveBeenCalledWith(12345);
});
test('should not call registerWebviewDevTools when webContentsId is not available', () => {
manager.handleDevToolsOpened();
expect(mockIpcApi.registerWebviewDevTools).not.toHaveBeenCalled();
});
test('should handle error from registerWebviewDevTools', () => {
const error = new Error('IPC error');
vi.mocked(mockIpcApi.registerWebviewDevTools).mockRejectedValue(error);
manager.handleDomReady(mockWebview);
manager.handleDevToolsOpened();
});
});
describe('cleanup', () => {
test('should call cleanupWebviewDevTools when both webContentsId and webview info are available', () => {
manager.handleDomReady(mockWebview);
manager.cleanup();
expect(mockIpcApi.cleanupWebviewDevTools).toHaveBeenCalledWith(12345);
});
test('should not call cleanupWebviewDevTools when webContentsId is not available', () => {
manager.cleanup();
expect(mockIpcApi.cleanupWebviewDevTools).not.toHaveBeenCalled();
});
test('should not call cleanupWebviewDevTools when webview info is not available', () => {
manager.updateWebviewInfo();
manager.handleDomReady(mockWebview);
manager.cleanup();
expect(mockIpcApi.cleanupWebviewDevTools).not.toHaveBeenCalled();
});
test('should handle error from cleanupWebviewDevTools', () => {
const error = new Error('Cleanup error');
vi.mocked(mockIpcApi.cleanupWebviewDevTools).mockRejectedValue(error);
manager.handleDomReady(mockWebview);
manager.cleanup();
});
});
describe('getWebContentsId', () => {
test('should return undefined initially', () => {
expect(manager.getWebContentsId()).toBeUndefined();
});
test('should return webContentsId after handleDomReady', () => {
manager.handleDomReady(mockWebview);
expect(manager.getWebContentsId()).toBe(12345);
});
});
describe('getWebviewInfo', () => {
test('should return current webview info', () => {
expect(manager.getWebviewInfo()).toBe(mockWebviewInfo);
});
test('should return updated webview info', () => {
const newInfo = { ...mockWebviewInfo, id: 'updated-id' };
manager.updateWebviewInfo(newInfo);
expect(manager.getWebviewInfo()).toBe(newInfo);
});
});
});

View file

@ -0,0 +1,129 @@
/**********************************************************************
* Copyright (C) 2025 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import type { WebviewInfo } from '/@api/webview-info';
interface WebviewEventMap {
'dom-ready': Event;
'devtools-opened': Event;
}
// Combine webview events with standard HTML events
type ExtendedEventMap = HTMLElementEventMap & WebviewEventMap;
export interface WebviewElement extends Omit<HTMLElement, 'addEventListener' | 'removeEventListener'> {
getWebContentsId(): number;
addEventListener<K extends keyof ExtendedEventMap>(
type: K,
listener: (this: WebviewElement, ev: ExtendedEventMap[K]) => unknown,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void;
removeEventListener<K extends keyof ExtendedEventMap>(
type: K,
listener: (this: WebviewElement, ev: ExtendedEventMap[K]) => unknown,
options?: boolean | EventListenerOptions,
): void;
removeEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
): void;
}
export interface IpcApi {
registerWebviewDevTools: (webcontentId: number) => Promise<void>;
cleanupWebviewDevTools: (webcontentId: number) => Promise<void>;
}
/**
* Manages the lifecycle of webview DevTools, handling registration and cleanup
* to prevent application crashes when webviews are destroyed while DevTools are open.
*/
export class WebviewLifecycleManager {
private webviewWebContentsId: number | undefined;
private webviewInfo: WebviewInfo | undefined;
constructor(
private readonly ipcApi: IpcApi,
webviewInfo?: WebviewInfo,
) {
this.webviewInfo = webviewInfo;
}
/**
* Updates the webview info when it changes
*/
updateWebviewInfo(webviewInfo?: WebviewInfo): void {
this.webviewInfo = webviewInfo;
}
/**
* Handles the 'dom-ready' event from the webview.
* Extracts and stores the webContentsId for later use.
*/
handleDomReady(webview: WebviewElement): void {
try {
this.webviewWebContentsId = webview.getWebContentsId();
} catch (err) {
console.error('Failed to get webview webContentsId:', err);
}
}
/**
* Handles the 'devtools-opened' event from the webview.
* Registers the webview with the DevTools management system.
*/
handleDevToolsOpened(): void {
if (this.webviewWebContentsId) {
this.ipcApi
.registerWebviewDevTools(this.webviewWebContentsId)
.catch((err: unknown) => console.error('Failed to track webview process after DevTools opened:', err));
}
}
/**
* Cleans up the webview resources when it's being destroyed.
* Should be called when the webview component is unmounted.
*/
cleanup(): void {
if (this.webviewWebContentsId && this.webviewInfo?.id) {
this.ipcApi
.cleanupWebviewDevTools(this.webviewWebContentsId)
.catch((err: unknown) => console.error('Failed to cleanup webview:', err));
}
}
/**
* Gets the current webContentsId if available
*/
getWebContentsId(): number | undefined {
return this.webviewWebContentsId;
}
/**
* Gets the current webview info
*/
getWebviewInfo(): WebviewInfo | undefined {
return this.webviewInfo;
}
}