mirror of
https://github.com/podman-desktop/podman-desktop
synced 2026-04-21 17:47:22 +00:00
feat: implement DevTools lifecycle management to prevent app crashes
Signed-off-by: Vladyslav Zhukovskyi <vzhukovs@redhat.com>
This commit is contained in:
parent
0ae6c2ebaa
commit
68e6a2436c
12 changed files with 1862 additions and 2 deletions
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
720
packages/main/src/plugin/webview/devtools-manager.spec.ts
Normal file
720
packages/main/src/plugin/webview/devtools-manager.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
177
packages/main/src/plugin/webview/devtools-manager.ts
Normal file
177
packages/main/src/plugin/webview/devtools-manager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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[]> => {
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
376
packages/renderer/src/lib/webview/webview-directive.spec.ts
Normal file
376
packages/renderer/src/lib/webview/webview-directive.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
216
packages/renderer/src/lib/webview/webview-directive.ts
Normal file
216
packages/renderer/src/lib/webview/webview-directive.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
129
packages/renderer/src/lib/webview/webview-lifecycle-manager.ts
Normal file
129
packages/renderer/src/lib/webview/webview-lifecycle-manager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue