Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ const esModules = [
'@codemirror',
'@jupyter',
'@microsoft',
'@deepnote',
'exenv-es6',
'lib0',
'nanoid',
'vscode-ws-jsonrpc',
'y-protocols',
'y-websocket',
'yjs'
'yjs',
'yaml'
].join('|');

const baseConfig = jestJupyterLab(__dirname);
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,12 @@
"args": "none"
}
],
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-namespace": "off",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-non-null-assertion": "error",
"@typescript-eslint/prefer-nullish-coalescing": "error",
"@typescript-eslint/quotes": [
"error",
"single",
Expand Down
100 changes: 87 additions & 13 deletions src/__tests__/NotebookPicker.spec.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,37 @@
// Copyright (c) Deepnote
// Distributed under the terms of the Modified BSD License.

import { NotebookPicker } from '../../src/components/NotebookPicker';
import type { NotebookPanel } from '@jupyterlab/notebook';
import { framePromise } from '@jupyterlab/testing';
import { NotebookPanel } from '@jupyterlab/notebook';
import { INotebookModel } from '@jupyterlab/notebook';
import { Widget } from '@lumino/widgets';
import { simulate } from 'simulate-event';
import { NotebookPicker } from '../../src/components/NotebookPicker';

// Mock types for testing
interface MockNotebookModel {
fromJSON: jest.Mock;
cells: unknown[];
dirty: boolean;
}

interface MockNotebookPanel {
context: {
ready: Promise<void>;
model: {
getMetadata: jest.Mock;
};
};
model: MockNotebookModel | null;
}

// Type for widget with overridden protected method
type WidgetWithMockOnAfterAttach = NotebookPicker & {
onAfterAttach: jest.Mock;
};

describe('NotebookPicker', () => {
let panel: NotebookPanel;
let model: INotebookModel;
let panel: MockNotebookPanel;
let model: MockNotebookModel;

beforeEach(async () => {
// Mock model + metadata
Expand All @@ -20,7 +41,7 @@ describe('NotebookPicker', () => {
return [];
},
dirty: true
} as any;
};

panel = {
context: {
Expand All @@ -36,12 +57,14 @@ describe('NotebookPicker', () => {
}
},
model
} as any;
};

// Attach to DOM
const widget = new NotebookPicker(panel);
const widget = new NotebookPicker(
panel as unknown as NotebookPanel
) as WidgetWithMockOnAfterAttach;
// Override onAfterAttach to avoid errors from this.parent being null
(widget as any).onAfterAttach = jest.fn();
widget.onAfterAttach = jest.fn();
Widget.attach(widget, document.body);
await framePromise();
});
Expand All @@ -56,7 +79,7 @@ describe('NotebookPicker', () => {
const select = document.querySelector('select') as HTMLSelectElement;
expect(select).not.toBeNull();
expect(select.options.length).toBe(2);
expect(select.options[0] && select.options[0].value).toBe('nb1');
expect(select.options[0]?.value).toBe('nb1');
});

it('should call fromJSON when selecting a notebook', async () => {
Expand Down Expand Up @@ -98,14 +121,65 @@ describe('NotebookPicker', () => {
getMetadata.mockReturnValue({ notebooks: {}, notebook_names: [] });

document.body.innerHTML = '';
const widget = new NotebookPicker(panel);
const widget = new NotebookPicker(
panel as unknown as NotebookPanel
) as WidgetWithMockOnAfterAttach;
// Override onAfterAttach to avoid errors from this.parent being null
(widget as any).onAfterAttach = jest.fn();
widget.onAfterAttach = jest.fn();
Widget.attach(widget, document.body);
await framePromise();

const select = document.querySelector('select') as HTMLSelectElement;
expect(select.options.length).toBeGreaterThanOrEqual(1);
expect(select.options[0] && select.options[0].value).toBe('-');
expect(select.options[0]?.value).toBe('-');
});

it('should handle null model in handleChange', async () => {
const nullModelPanel: MockNotebookPanel = {
context: {
ready: Promise.resolve(),
model: {
getMetadata: jest.fn().mockReturnValue({
notebooks: {
nb1: { id: 'nb1', name: 'nb1', cells: [] }
},
notebook_names: ['nb1']
})
}
},
model: null
};

document.body.innerHTML = '';
const widget = new NotebookPicker(
nullModelPanel as unknown as NotebookPanel
) as WidgetWithMockOnAfterAttach;
widget.onAfterAttach = jest.fn();
Widget.attach(widget, document.body);
await framePromise();

const select = document.querySelector('select') as HTMLSelectElement;
simulate(select, 'change', { target: { value: 'nb1' } });
await framePromise();
});

it('should handle invalid metadata in handleChange', async () => {
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
const getMetadata = panel.context.model.getMetadata as jest.Mock;
getMetadata.mockReturnValue({ invalid: 'metadata' });

const select = document.querySelector('select') as HTMLSelectElement;
simulate(select, 'change', { target: { value: 'nb1' } });
await framePromise();

expect(consoleErrorSpy).toHaveBeenCalledWith(
'Invalid deepnote metadata:',
expect.anything()
);
expect(model.fromJSON).not.toHaveBeenCalled();

consoleErrorSpy.mockRestore();
});
});
31 changes: 19 additions & 12 deletions src/components/NotebookPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,23 @@ export class NotebookPicker extends ReactWidget {
constructor(private panel: NotebookPanel) {
super();

void panel.context.ready.then(() => {
const deepnoteMetadata = this.panel.context.model.getMetadata('deepnote');
const metadataNames = deepnoteMetadata?.notebook_names;
const names =
Array.isArray(metadataNames) &&
metadataNames.every(n => typeof n === 'string')
? metadataNames
: [];
panel.context.ready
.then(() => {
const deepnoteMetadata =
this.panel.context.model.getMetadata('deepnote');
const metadataNames = deepnoteMetadata?.notebook_names;
const names =
Array.isArray(metadataNames) &&
metadataNames.every(n => typeof n === 'string')
? metadataNames
: [];

this.selected = names.length === 0 ? null : (names[0] ?? null);
this.update();
});
this.selected = names.length === 0 ? null : (names[0] ?? null);
this.update();
})
.catch(error => {
console.error('Failed to initialize NotebookPicker:', error);
});
}

private handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
Expand Down Expand Up @@ -68,7 +73,9 @@ export class NotebookPicker extends ReactWidget {
protected onAfterAttach(msg: Message): void {
super.onAfterAttach(msg);
requestAnimationFrame(() => {
MessageLoop.sendMessage(this.parent!, Widget.ResizeMessage.UnknownSize);
if (this.parent) {
MessageLoop.sendMessage(this.parent, Widget.ResizeMessage.UnknownSize);
}
});
}

Expand Down
4 changes: 3 additions & 1 deletion src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ export async function requestAPI(
try {
response = await ServerConnection.makeRequest(requestUrl, init, settings);
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
throw new ServerConnection.NetworkError(error as any);
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
let data: any = await response.text();

if (data.length > 0) {
Expand All @@ -39,7 +41,7 @@ export async function requestAPI(
}

if (!response.ok) {
throw new ServerConnection.ResponseError(response, data.message || data);
throw new ServerConnection.ResponseError(response, data.message ?? data);
}

return data;
Expand Down