Skip to content

Commit a574216

Browse files
authored
Merge branch 'master' into phase2/e2e-suite
2 parents 23e7837 + aded203 commit a574216

16 files changed

Lines changed: 1132 additions & 39 deletions

test/setup/electron-mock.ts

Lines changed: 18 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,27 @@
11
import { vi } from 'vitest';
22
import { tmpdir } from 'os';
33
import { join } from 'path';
4+
import * as electronStub from './electron-stub';
45

5-
const userDataPath = join(tmpdir(), 'jlab-test-userdata');
66
const logFilePath = join(tmpdir(), 'jlab-test.log');
77

8-
vi.mock('electron', () => ({
9-
app: {
10-
getPath: vi.fn((name: string) => join(userDataPath, name)),
11-
getVersion: vi.fn(() => '4.4.7'),
12-
getName: vi.fn(() => 'JupyterLab'),
13-
isPackaged: false,
14-
whenReady: vi.fn(() => Promise.resolve())
15-
},
16-
ipcMain: {
17-
on: vi.fn(),
18-
handle: vi.fn(),
19-
removeHandler: vi.fn(),
20-
removeListener: vi.fn(),
21-
removeAllListeners: vi.fn(),
22-
emit: vi.fn()
23-
},
24-
dialog: {
25-
showOpenDialog: vi.fn(),
26-
showMessageBox: vi.fn(),
27-
showMessageBoxSync: vi.fn(() => 0)
28-
},
29-
BrowserWindow: vi.fn().mockImplementation(() => ({
30-
loadURL: vi.fn(),
31-
webContents: { send: vi.fn(), on: vi.fn() },
32-
on: vi.fn(),
33-
once: vi.fn(),
34-
show: vi.fn(),
35-
close: vi.fn(),
36-
isDestroyed: vi.fn(() => false)
37-
})),
38-
shell: { openExternal: vi.fn(), openPath: vi.fn() },
39-
nativeTheme: { shouldUseDarkColors: false },
40-
screen: {
41-
getPrimaryDisplay: vi.fn(() => ({
42-
workAreaSize: { width: 1920, height: 1080 }
43-
}))
44-
}
45-
}));
8+
// `electron` is mocked two ways pointing at the same ./electron-stub instances:
9+
// - vitest.config `resolve.alias` redirects ESM `import ... from 'electron'`.
10+
// - vitest leaves CJS `require('electron')` (used by the preload scripts)
11+
// externalized, so it hits Node's require and would return the binary path
12+
// string. Pre-seeding require.cache for electron's resolved id makes that
13+
// require return the stub instead. Both paths share electronStub's vi.fns.
14+
try {
15+
const electronId = require.resolve('electron');
16+
(require.cache as Record<string, unknown>)[electronId] = {
17+
id: electronId,
18+
filename: electronId,
19+
loaded: true,
20+
exports: electronStub
21+
};
22+
} catch {
23+
// electron not resolvable in this environment; ESM alias still applies.
24+
}
4625

4726
vi.mock('electron-log', () => ({
4827
default: {

test/setup/electron-stub.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Single source of truth for the mocked `electron` module.
2+
// Aliased in vitest.config so BOTH `import ... from 'electron'` (ESM) and
3+
// `require('electron')` (used by the preload scripts) resolve here to the same
4+
// vi.fn instances. Native require would otherwise return the electron binary
5+
// path string and bypass vi.mock, leaving preload code untestable.
6+
import { vi } from 'vitest';
7+
import { tmpdir } from 'os';
8+
import { join } from 'path';
9+
10+
const userDataPath = join(tmpdir(), 'jlab-test-userdata');
11+
12+
export const app = {
13+
getPath: vi.fn((name: string) => join(userDataPath, name)),
14+
getVersion: vi.fn(() => '4.4.7'),
15+
getName: vi.fn(() => 'JupyterLab'),
16+
isPackaged: false,
17+
whenReady: vi.fn(() => Promise.resolve())
18+
};
19+
20+
export const ipcMain = {
21+
on: vi.fn(),
22+
handle: vi.fn(),
23+
removeHandler: vi.fn(),
24+
removeListener: vi.fn(),
25+
removeAllListeners: vi.fn(),
26+
emit: vi.fn()
27+
};
28+
29+
export const ipcRenderer = {
30+
invoke: vi.fn(),
31+
send: vi.fn(),
32+
on: vi.fn(),
33+
removeListener: vi.fn()
34+
};
35+
36+
export const contextBridge = {
37+
exposeInMainWorld: vi.fn()
38+
};
39+
40+
export const dialog = {
41+
showOpenDialog: vi.fn(),
42+
showMessageBox: vi.fn(),
43+
showMessageBoxSync: vi.fn(() => 0)
44+
};
45+
46+
export const BrowserWindow = vi.fn().mockImplementation(() => ({
47+
loadURL: vi.fn(),
48+
webContents: { send: vi.fn(), on: vi.fn() },
49+
on: vi.fn(),
50+
once: vi.fn(),
51+
show: vi.fn(),
52+
close: vi.fn(),
53+
isDestroyed: vi.fn(() => false)
54+
}));
55+
56+
export const shell = { openExternal: vi.fn(), openPath: vi.fn() };
57+
58+
export const nativeTheme = { shouldUseDarkColors: false };
59+
60+
export const screen = {
61+
getPrimaryDisplay: vi.fn(() => ({
62+
workAreaSize: { width: 1920, height: 1080 }
63+
}))
64+
};
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { EventTypeMain } from '../../../src/main/eventtypes';
3+
import { exposedAPI, ipcRenderer } from './helpers';
4+
5+
async function load(): Promise<Record<string, any>> {
6+
await import('../../../src/main/aboutdialog/preload');
7+
return exposedAPI();
8+
}
9+
10+
describe('aboutdialog preload', () => {
11+
beforeEach(() => {
12+
vi.clearAllMocks();
13+
vi.resetModules();
14+
});
15+
16+
it('exposes exactly the documented electronAPI keys', async () => {
17+
const api = await load();
18+
expect(Object.keys(api).sort()).toEqual(
19+
['getAppConfig', 'isDarkTheme', 'launchAboutJupyterPage'].sort()
20+
);
21+
});
22+
23+
it('getAppConfig returns the platform without touching IPC', async () => {
24+
const api = await load();
25+
expect(api.getAppConfig()).toEqual({ platform: process.platform });
26+
expect(ipcRenderer.invoke).not.toHaveBeenCalled();
27+
expect(ipcRenderer.send).not.toHaveBeenCalled();
28+
});
29+
30+
it('isDarkTheme forwards to invoke on the IsDarkTheme channel', async () => {
31+
const api = await load();
32+
api.isDarkTheme();
33+
expect(ipcRenderer.invoke).toHaveBeenCalledWith(EventTypeMain.IsDarkTheme);
34+
});
35+
36+
it('launchAboutJupyterPage forwards to send on the LaunchAboutJupyterPage channel', async () => {
37+
const api = await load();
38+
api.launchAboutJupyterPage();
39+
expect(ipcRenderer.send).toHaveBeenCalledWith(
40+
EventTypeMain.LaunchAboutJupyterPage
41+
);
42+
});
43+
44+
it('does not expose the raw ipcRenderer object', async () => {
45+
const api = await load();
46+
expect(Object.values(api)).not.toContain(ipcRenderer);
47+
});
48+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { EventTypeMain } from '../../../src/main/eventtypes';
3+
import { exposedAPI, ipcRenderer } from './helpers';
4+
5+
async function load(): Promise<Record<string, any>> {
6+
await import('../../../src/main/authdialog/preload');
7+
return exposedAPI();
8+
}
9+
10+
describe('authdialog preload', () => {
11+
beforeEach(() => {
12+
vi.clearAllMocks();
13+
vi.resetModules();
14+
});
15+
16+
it('exposes exactly the documented electronAPI keys', async () => {
17+
const api = await load();
18+
expect(Object.keys(api).sort()).toEqual(
19+
['getAppConfig', 'isDarkTheme', 'setAuthDialogResponse'].sort()
20+
);
21+
});
22+
23+
it('getAppConfig returns the platform without touching IPC', async () => {
24+
const api = await load();
25+
expect(api.getAppConfig()).toEqual({ platform: process.platform });
26+
expect(ipcRenderer.invoke).not.toHaveBeenCalled();
27+
expect(ipcRenderer.send).not.toHaveBeenCalled();
28+
});
29+
30+
it('isDarkTheme forwards to invoke on the IsDarkTheme channel', async () => {
31+
const api = await load();
32+
api.isDarkTheme();
33+
expect(ipcRenderer.invoke).toHaveBeenCalledWith(EventTypeMain.IsDarkTheme);
34+
});
35+
36+
it('setAuthDialogResponse forwards username and password on SetAuthDialogResponse', async () => {
37+
const api = await load();
38+
api.setAuthDialogResponse('user', 'pass');
39+
expect(ipcRenderer.send).toHaveBeenCalledWith(
40+
EventTypeMain.SetAuthDialogResponse,
41+
'user',
42+
'pass'
43+
);
44+
});
45+
46+
it('does not expose the raw ipcRenderer object', async () => {
47+
const api = await load();
48+
expect(Object.values(api)).not.toContain(ipcRenderer);
49+
});
50+
});

test/unit/preload/dialog.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { EventTypeMain } from '../../../src/main/eventtypes';
3+
import { exposedAPI, ipcRenderer } from './helpers';
4+
5+
async function load(): Promise<Record<string, any>> {
6+
await import('../../../src/main/dialog/preload');
7+
return exposedAPI();
8+
}
9+
10+
describe('dialog preload', () => {
11+
beforeEach(() => {
12+
vi.clearAllMocks();
13+
vi.resetModules();
14+
});
15+
16+
it('exposes exactly the documented electronAPI keys', async () => {
17+
const api = await load();
18+
expect(Object.keys(api).sort()).toEqual(
19+
['getAppConfig', 'isDarkTheme'].sort()
20+
);
21+
});
22+
23+
it('getAppConfig returns the platform without touching IPC', async () => {
24+
const api = await load();
25+
expect(api.getAppConfig()).toEqual({ platform: process.platform });
26+
expect(ipcRenderer.invoke).not.toHaveBeenCalled();
27+
expect(ipcRenderer.send).not.toHaveBeenCalled();
28+
});
29+
30+
it('isDarkTheme forwards to invoke on the IsDarkTheme channel', async () => {
31+
const api = await load();
32+
api.isDarkTheme();
33+
expect(ipcRenderer.invoke).toHaveBeenCalledWith(EventTypeMain.IsDarkTheme);
34+
});
35+
36+
it('does not expose the raw ipcRenderer object', async () => {
37+
const api = await load();
38+
expect(Object.values(api)).not.toContain(ipcRenderer);
39+
});
40+
});

test/unit/preload/helpers.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { contextBridge, ipcRenderer } from 'electron';
2+
import { vi } from 'vitest';
3+
4+
export { ipcRenderer };
5+
6+
// The API object the preload handed to contextBridge.exposeInMainWorld('electronAPI', ...).
7+
export function exposedAPI(): Record<string, any> {
8+
const call = vi
9+
.mocked(contextBridge.exposeInMainWorld)
10+
.mock.calls.find(c => c[0] === 'electronAPI');
11+
if (!call) {
12+
throw new Error('preload did not expose an "electronAPI" namespace');
13+
}
14+
return call[1] as Record<string, any>;
15+
}
16+
17+
// The handler the preload registered for a given EventTypeRenderer channel via
18+
// ipcRenderer.on at module load. Invoke it to simulate a message from main.
19+
export function rendererHandler(channel: string): (...args: any[]) => void {
20+
const call = vi.mocked(ipcRenderer.on).mock.calls.find(c => c[0] === channel);
21+
if (!call) {
22+
throw new Error(`no ipcRenderer.on listener registered for "${channel}"`);
23+
}
24+
return call[1] as (...args: any[]) => void;
25+
}

test/unit/preload/labview.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { EventTypeMain } from '../../../src/main/eventtypes';
3+
import { exposedAPI, ipcRenderer } from './helpers';
4+
5+
async function load(): Promise<Record<string, any>> {
6+
await import('../../../src/main/labview/preload');
7+
return exposedAPI();
8+
}
9+
10+
describe('labview preload', () => {
11+
beforeEach(() => {
12+
vi.clearAllMocks();
13+
vi.resetModules();
14+
});
15+
16+
it('exposes exactly the documented electronAPI keys', async () => {
17+
const api = await load();
18+
expect(Object.keys(api).sort()).toEqual(
19+
['getServerInfo', 'broadcastLabUIReady'].sort()
20+
);
21+
});
22+
23+
it('getServerInfo forwards to invoke on the GetServerInfo channel', async () => {
24+
const api = await load();
25+
api.getServerInfo();
26+
expect(ipcRenderer.invoke).toHaveBeenCalledWith(
27+
EventTypeMain.GetServerInfo
28+
);
29+
});
30+
31+
it('broadcastLabUIReady forwards to send on the LabUIReady channel', async () => {
32+
const api = await load();
33+
api.broadcastLabUIReady();
34+
expect(ipcRenderer.send).toHaveBeenCalledWith(EventTypeMain.LabUIReady);
35+
});
36+
37+
it('does not expose the raw ipcRenderer object', async () => {
38+
const api = await load();
39+
expect(Object.values(api)).not.toContain(ipcRenderer);
40+
});
41+
});

0 commit comments

Comments
 (0)