From b6bbb3a4e36b8c442c666a5ede6d0fdef95c2ab6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 23 Oct 2025 17:07:07 +0000 Subject: [PATCH 1/7] Add comprehensive test coverage for core transformation functions - Add tests for convert-deepnote-block-type-to-jupyter.ts (100% coverage) - Add tests for convert-deepnote-block-to-jupyter-cell.ts (100% coverage) - Add tests for transform-deepnote-yaml-to-notebook-content.ts (100% coverage) This increases overall test coverage from 26.57% to 55.94%, providing comprehensive testing for the core Deepnote-to-Jupyter transformation logic. --- ...ert-deepnote-block-to-jupyter-cell.spec.ts | 293 +++++++++++++++++ ...ert-deepnote-block-type-to-jupyter.spec.ts | 144 ++++++++ ...-deepnote-yaml-to-notebook-content.spec.ts | 310 ++++++++++++++++++ 3 files changed, 747 insertions(+) create mode 100644 src/__tests__/convert-deepnote-block-to-jupyter-cell.spec.ts create mode 100644 src/__tests__/convert-deepnote-block-type-to-jupyter.spec.ts create mode 100644 src/__tests__/transform-deepnote-yaml-to-notebook-content.spec.ts diff --git a/src/__tests__/convert-deepnote-block-to-jupyter-cell.spec.ts b/src/__tests__/convert-deepnote-block-to-jupyter-cell.spec.ts new file mode 100644 index 0000000..4cc597f --- /dev/null +++ b/src/__tests__/convert-deepnote-block-to-jupyter-cell.spec.ts @@ -0,0 +1,293 @@ +// Copyright (c) Deepnote +// Distributed under the terms of the Modified BSD License. + +import { convertDeepnoteBlockToJupyterCell } from '../convert-deepnote-block-to-jupyter-cell'; +import { DeepnoteBlock } from '@deepnote/blocks'; + +jest.mock('@deepnote/blocks', () => ({ + createPythonCode: jest.fn((block: any) => block.source || 'print("test")'), + createMarkdown: jest.fn((block: any) => block.source || '# Test') +})); + +jest.mock('../convert-deepnote-block-type-to-jupyter', () => ({ + convertDeepnoteBlockTypeToJupyter: jest.fn((type: string) => { + if ( + [ + 'code', + 'sql', + 'notebook-function', + 'big-number', + 'visualization', + 'input-text', + 'input-checkbox', + 'input-textarea', + 'input-file', + 'input-select', + 'input-date-range', + 'input-date', + 'input-slider' + ].includes(type) + ) { + return 'code'; + } + return 'markdown'; + }) +})); + +describe('convertDeepnoteBlockToJupyterCell', () => { + describe('code cells', () => { + it('should convert a basic code block to a Jupyter code cell', () => { + const block: DeepnoteBlock = { + id: 'block-1', + type: 'code', + source: 'print("hello")', + metadata: { foo: 'bar' } + } as any; + + const result = convertDeepnoteBlockToJupyterCell(block); + + expect(result.cell_type).toBe('code'); + expect(result.metadata).toEqual({ foo: 'bar', cell_id: 'block-1' }); + expect(result.source).toBe('print("hello")'); + expect(result.execution_count).toBeNull(); + expect(result.outputs).toEqual([]); + }); + + it('should include execution count if present', () => { + const block: DeepnoteBlock = { + id: 'block-2', + type: 'code', + source: 'x = 1', + metadata: {}, + executionCount: 5 + } as any; + + const result = convertDeepnoteBlockToJupyterCell(block); + + expect(result.cell_type).toBe('code'); + expect(result.execution_count).toBe(5); + }); + + it('should include outputs if present', () => { + const blockOutputs = [ + { + output_type: 'stream', + name: 'stdout', + text: 'hello\n' + } + ]; + + const block: DeepnoteBlock = { + id: 'block-3', + type: 'code', + source: 'print("hello")', + metadata: {}, + outputs: blockOutputs + } as any; + + const result = convertDeepnoteBlockToJupyterCell(block); + + expect(result.cell_type).toBe('code'); + expect(result.outputs).toEqual(blockOutputs); + }); + + it('should remove truncated property from outputs', () => { + const blockOutputs = [ + { + output_type: 'stream', + name: 'stdout', + text: 'hello\n', + truncated: true + } + ]; + + const block: DeepnoteBlock = { + id: 'block-4', + type: 'code', + source: 'print("hello")', + metadata: {}, + outputs: blockOutputs + } as any; + + const result = convertDeepnoteBlockToJupyterCell(block); + + expect(result.cell_type).toBe('code'); + expect(result.outputs).toHaveLength(1); + const resultOutputs = result.outputs as any[]; + expect(resultOutputs[0]).not.toHaveProperty('truncated'); + expect(resultOutputs[0]).toEqual({ + output_type: 'stream', + name: 'stdout', + text: 'hello\n' + }); + }); + + it('should handle multiple outputs with truncated properties', () => { + const blockOutputs = [ + { + output_type: 'stream', + name: 'stdout', + text: 'line1\n', + truncated: true + }, + { + output_type: 'stream', + name: 'stdout', + text: 'line2\n', + truncated: false + } + ]; + + const block: DeepnoteBlock = { + id: 'block-5', + type: 'code', + source: 'print("test")', + metadata: {}, + outputs: blockOutputs + } as any; + + const result = convertDeepnoteBlockToJupyterCell(block); + + expect(result.cell_type).toBe('code'); + expect(result.outputs).toHaveLength(2); + const resultOutputs = result.outputs as any[]; + expect(resultOutputs[0]).not.toHaveProperty('truncated'); + expect(resultOutputs[1]).not.toHaveProperty('truncated'); + }); + + it('should not mutate the original block', () => { + const blockOutputs = [ + { + output_type: 'stream', + name: 'stdout', + text: 'hello\n', + truncated: true + } + ]; + + const block: DeepnoteBlock = { + id: 'block-6', + type: 'code', + source: 'print("hello")', + metadata: { test: 'value' }, + outputs: blockOutputs + } as any; + + convertDeepnoteBlockToJupyterCell(block); + + expect(block.outputs![0]).toHaveProperty('truncated'); + expect(block.metadata).toEqual({ test: 'value' }); + }); + }); + + describe('markdown cells', () => { + it('should convert a basic markdown block to a Jupyter markdown cell', () => { + const block: DeepnoteBlock = { + id: 'block-7', + type: 'markdown', + source: '# Hello', + metadata: { foo: 'bar' } + } as any; + + const result = convertDeepnoteBlockToJupyterCell(block); + + expect(result.cell_type).toBe('markdown'); + expect(result.metadata).toEqual({}); + expect(result.source).toBe('# Hello'); + }); + + it('should convert text-cell-h1 to markdown cell', () => { + const block: DeepnoteBlock = { + id: 'block-8', + type: 'text-cell-h1', + source: 'Heading 1', + metadata: {} + } as any; + + const result = convertDeepnoteBlockToJupyterCell(block); + + expect(result.cell_type).toBe('markdown'); + }); + + it('should convert image block to markdown cell', () => { + const block: DeepnoteBlock = { + id: 'block-9', + type: 'image', + source: '![alt](url)', + metadata: {} + } as any; + + const result = convertDeepnoteBlockToJupyterCell(block); + + expect(result.cell_type).toBe('markdown'); + }); + + it('should not include metadata from Deepnote block in markdown cells', () => { + const block: DeepnoteBlock = { + id: 'block-10', + type: 'markdown', + source: 'Text', + metadata: { deepnoteMetadata: 'should not appear' } + } as any; + + const result = convertDeepnoteBlockToJupyterCell(block); + + expect(result.cell_type).toBe('markdown'); + expect(result.metadata).toEqual({}); + }); + }); + + describe('special block types', () => { + it('should convert sql block to code cell', () => { + const block: DeepnoteBlock = { + id: 'block-11', + type: 'sql', + source: 'SELECT * FROM table', + metadata: {} + } as any; + + const result = convertDeepnoteBlockToJupyterCell(block); + + expect(result.cell_type).toBe('code'); + }); + + it('should convert visualization block to code cell', () => { + const block: DeepnoteBlock = { + id: 'block-12', + type: 'visualization', + source: 'chart_data', + metadata: {} + } as any; + + const result = convertDeepnoteBlockToJupyterCell(block); + + expect(result.cell_type).toBe('code'); + }); + + it('should convert input blocks to code cells', () => { + const inputTypes = [ + 'input-text', + 'input-checkbox', + 'input-textarea', + 'input-file', + 'input-select', + 'input-date-range', + 'input-date', + 'input-slider' + ]; + + inputTypes.forEach(type => { + const block: DeepnoteBlock = { + id: `block-${type}`, + type, + source: 'input_value', + metadata: {} + } as any; + + const result = convertDeepnoteBlockToJupyterCell(block); + + expect(result.cell_type).toBe('code'); + }); + }); + }); +}); diff --git a/src/__tests__/convert-deepnote-block-type-to-jupyter.spec.ts b/src/__tests__/convert-deepnote-block-type-to-jupyter.spec.ts new file mode 100644 index 0000000..537584d --- /dev/null +++ b/src/__tests__/convert-deepnote-block-type-to-jupyter.spec.ts @@ -0,0 +1,144 @@ +// Copyright (c) Deepnote +// Distributed under the terms of the Modified BSD License. + +import { convertDeepnoteBlockTypeToJupyter } from '../convert-deepnote-block-type-to-jupyter'; + +describe('convertDeepnoteBlockTypeToJupyter', () => { + describe('code block types', () => { + it('should convert "code" to "code"', () => { + expect(convertDeepnoteBlockTypeToJupyter('code')).toBe('code'); + }); + + it('should convert "sql" to "code"', () => { + expect(convertDeepnoteBlockTypeToJupyter('sql')).toBe('code'); + }); + + it('should convert "notebook-function" to "code"', () => { + expect(convertDeepnoteBlockTypeToJupyter('notebook-function')).toBe( + 'code' + ); + }); + + it('should convert "big-number" to "code"', () => { + expect(convertDeepnoteBlockTypeToJupyter('big-number')).toBe('code'); + }); + + it('should convert "visualization" to "code"', () => { + expect(convertDeepnoteBlockTypeToJupyter('visualization')).toBe('code'); + }); + + describe('input block types', () => { + it('should convert "input-text" to "code"', () => { + expect(convertDeepnoteBlockTypeToJupyter('input-text')).toBe('code'); + }); + + it('should convert "input-checkbox" to "code"', () => { + expect(convertDeepnoteBlockTypeToJupyter('input-checkbox')).toBe( + 'code' + ); + }); + + it('should convert "input-textarea" to "code"', () => { + expect(convertDeepnoteBlockTypeToJupyter('input-textarea')).toBe( + 'code' + ); + }); + + it('should convert "input-file" to "code"', () => { + expect(convertDeepnoteBlockTypeToJupyter('input-file')).toBe('code'); + }); + + it('should convert "input-select" to "code"', () => { + expect(convertDeepnoteBlockTypeToJupyter('input-select')).toBe('code'); + }); + + it('should convert "input-date-range" to "code"', () => { + expect(convertDeepnoteBlockTypeToJupyter('input-date-range')).toBe( + 'code' + ); + }); + + it('should convert "input-date" to "code"', () => { + expect(convertDeepnoteBlockTypeToJupyter('input-date')).toBe('code'); + }); + + it('should convert "input-slider" to "code"', () => { + expect(convertDeepnoteBlockTypeToJupyter('input-slider')).toBe('code'); + }); + }); + }); + + describe('markdown block types', () => { + it('should convert "markdown" to "markdown"', () => { + expect(convertDeepnoteBlockTypeToJupyter('markdown')).toBe('markdown'); + }); + + it('should convert "image" to "markdown"', () => { + expect(convertDeepnoteBlockTypeToJupyter('image')).toBe('markdown'); + }); + + it('should convert "button" to "markdown"', () => { + expect(convertDeepnoteBlockTypeToJupyter('button')).toBe('markdown'); + }); + + it('should convert "separator" to "markdown"', () => { + expect(convertDeepnoteBlockTypeToJupyter('separator')).toBe('markdown'); + }); + + describe('text cell types', () => { + it('should convert "text-cell-h1" to "markdown"', () => { + expect(convertDeepnoteBlockTypeToJupyter('text-cell-h1')).toBe( + 'markdown' + ); + }); + + it('should convert "text-cell-h2" to "markdown"', () => { + expect(convertDeepnoteBlockTypeToJupyter('text-cell-h2')).toBe( + 'markdown' + ); + }); + + it('should convert "text-cell-h3" to "markdown"', () => { + expect(convertDeepnoteBlockTypeToJupyter('text-cell-h3')).toBe( + 'markdown' + ); + }); + + it('should convert "text-cell-p" to "markdown"', () => { + expect(convertDeepnoteBlockTypeToJupyter('text-cell-p')).toBe( + 'markdown' + ); + }); + + it('should convert "text-cell-bullet" to "markdown"', () => { + expect(convertDeepnoteBlockTypeToJupyter('text-cell-bullet')).toBe( + 'markdown' + ); + }); + + it('should convert "text-cell-todo" to "markdown"', () => { + expect(convertDeepnoteBlockTypeToJupyter('text-cell-todo')).toBe( + 'markdown' + ); + }); + + it('should convert "text-cell-callout" to "markdown"', () => { + expect(convertDeepnoteBlockTypeToJupyter('text-cell-callout')).toBe( + 'markdown' + ); + }); + }); + }); + + describe('unknown block types', () => { + it('should convert unknown types to "markdown" (default)', () => { + expect(convertDeepnoteBlockTypeToJupyter('unknown-type')).toBe( + 'markdown' + ); + }); + + it('should convert empty string to "markdown" (default)', () => { + expect(convertDeepnoteBlockTypeToJupyter('')).toBe('markdown'); + }); + }); +}); diff --git a/src/__tests__/transform-deepnote-yaml-to-notebook-content.spec.ts b/src/__tests__/transform-deepnote-yaml-to-notebook-content.spec.ts new file mode 100644 index 0000000..bf214a3 --- /dev/null +++ b/src/__tests__/transform-deepnote-yaml-to-notebook-content.spec.ts @@ -0,0 +1,310 @@ +// Copyright (c) Deepnote +// Distributed under the terms of the Modified BSD License. + +import { transformDeepnoteYamlToNotebookContent } from '../transform-deepnote-yaml-to-notebook-content'; +import { deserializeDeepnoteFile } from '@deepnote/blocks'; + +jest.mock('@deepnote/blocks', () => ({ + deserializeDeepnoteFile: jest.fn() +})); + +jest.mock('../convert-deepnote-block-to-jupyter-cell', () => ({ + convertDeepnoteBlockToJupyterCell: jest.fn(block => ({ + cell_type: block.type === 'code' ? 'code' : 'markdown', + source: block.source || '', + metadata: block.type === 'code' ? { cell_id: block.id } : {}, + ...(block.type === 'code' && { + execution_count: block.executionCount || null, + outputs: block.outputs || [] + }) + })) +})); + +describe('transformDeepnoteYamlToNotebookContent', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should transform a simple Deepnote YAML to notebook content', async () => { + const mockDeepnoteFile = { + project: { + notebooks: [ + { + id: 'notebook-1', + name: 'Main Notebook', + blocks: [ + { + id: 'block-1', + type: 'code', + source: 'print("hello")', + metadata: {} + }, + { + id: 'block-2', + type: 'markdown', + source: '# Title', + metadata: {} + } + ] + } + ] + } + }; + + (deserializeDeepnoteFile as jest.Mock).mockResolvedValue(mockDeepnoteFile); + + const result = await transformDeepnoteYamlToNotebookContent('yaml-string'); + + expect(result.cells).toHaveLength(2); + const cells = result.cells as any[]; + expect(cells[0].cell_type).toBe('code'); + expect(cells[1].cell_type).toBe('markdown'); + expect(result.metadata.deepnote.notebooks).toHaveProperty('Main Notebook'); + expect(result.nbformat).toBe(4); + expect(result.nbformat_minor).toBe(0); + }); + + it('should include metadata for all notebooks', async () => { + const mockDeepnoteFile = { + project: { + notebooks: [ + { + id: 'notebook-1', + name: 'First Notebook', + blocks: [ + { + id: 'block-1', + type: 'code', + source: 'x = 1', + metadata: {} + } + ] + }, + { + id: 'notebook-2', + name: 'Second Notebook', + blocks: [ + { + id: 'block-2', + type: 'markdown', + source: '# Second', + metadata: {} + } + ] + } + ] + } + }; + + (deserializeDeepnoteFile as jest.Mock).mockResolvedValue(mockDeepnoteFile); + + const result = await transformDeepnoteYamlToNotebookContent('yaml-string'); + + expect(result.metadata.deepnote.notebooks).toHaveProperty('First Notebook'); + expect(result.metadata.deepnote.notebooks).toHaveProperty( + 'Second Notebook' + ); + expect(result.metadata.deepnote.notebooks['First Notebook']!.id).toBe( + 'notebook-1' + ); + expect(result.metadata.deepnote.notebooks['Second Notebook']!.id).toBe( + 'notebook-2' + ); + }); + + it('should use the first notebook for primary cell content', async () => { + const mockDeepnoteFile = { + project: { + notebooks: [ + { + id: 'notebook-1', + name: 'First', + blocks: [ + { + id: 'block-1', + type: 'code', + source: 'first_notebook_code', + metadata: {} + } + ] + }, + { + id: 'notebook-2', + name: 'Second', + blocks: [ + { + id: 'block-2', + type: 'code', + source: 'second_notebook_code', + metadata: {} + } + ] + } + ] + } + }; + + (deserializeDeepnoteFile as jest.Mock).mockResolvedValue(mockDeepnoteFile); + + const result = await transformDeepnoteYamlToNotebookContent('yaml-string'); + + expect(result.cells).toHaveLength(1); + const cells = result.cells as any[]; + expect(cells[0].source).toBe('first_notebook_code'); + }); + + it('should handle empty notebooks gracefully', async () => { + const mockDeepnoteFile = { + project: { + notebooks: [ + { + id: 'notebook-1', + name: 'Empty Notebook', + blocks: [] + } + ] + } + }; + + (deserializeDeepnoteFile as jest.Mock).mockResolvedValue(mockDeepnoteFile); + + const result = await transformDeepnoteYamlToNotebookContent('yaml-string'); + + expect(result.cells).toHaveLength(0); + expect(result.metadata.deepnote.notebooks).toHaveProperty('Empty Notebook'); + }); + + it('should handle file with no notebooks', async () => { + const mockDeepnoteFile = { + project: { + notebooks: [] + } + }; + + (deserializeDeepnoteFile as jest.Mock).mockResolvedValue(mockDeepnoteFile); + + const result = await transformDeepnoteYamlToNotebookContent('yaml-string'); + + expect(result.cells).toHaveLength(1); + const cells = result.cells as any[]; + expect(cells[0].cell_type).toBe('code'); + expect(cells[0].source).toContain('No notebooks found'); + }); + + it('should include kernel metadata', async () => { + const mockDeepnoteFile = { + project: { + notebooks: [ + { + id: 'notebook-1', + name: 'Test', + blocks: [] + } + ] + } + }; + + (deserializeDeepnoteFile as jest.Mock).mockResolvedValue(mockDeepnoteFile); + + const result = await transformDeepnoteYamlToNotebookContent('yaml-string'); + + expect(result.metadata).toHaveProperty('kernelspec'); + expect(result.metadata).toHaveProperty('language_info'); + expect(result.metadata.kernelspec).toHaveProperty('name', 'python3'); + expect(result.metadata.language_info).toHaveProperty('name', 'python'); + }); + + it('should throw error when deserialization fails', async () => { + (deserializeDeepnoteFile as jest.Mock).mockRejectedValue( + new Error('Invalid YAML') + ); + + await expect( + transformDeepnoteYamlToNotebookContent('invalid-yaml') + ).rejects.toThrow('Failed to transform Deepnote YAML to notebook content.'); + }); + + it('should preserve notebook structure in metadata', async () => { + const mockDeepnoteFile = { + project: { + notebooks: [ + { + id: 'notebook-1', + name: 'Test Notebook', + blocks: [ + { + id: 'block-1', + type: 'code', + source: 'x = 1', + metadata: {}, + executionCount: 5, + outputs: [{ output_type: 'stream', text: 'output' }] + } + ] + } + ] + } + }; + + (deserializeDeepnoteFile as jest.Mock).mockResolvedValue(mockDeepnoteFile); + + const result = await transformDeepnoteYamlToNotebookContent('yaml-string'); + + const notebookMetadata = + result.metadata.deepnote.notebooks['Test Notebook']!; + expect(notebookMetadata.id).toBe('notebook-1'); + expect(notebookMetadata.name).toBe('Test Notebook'); + expect(notebookMetadata.cells).toHaveLength(1); + expect(notebookMetadata.cells[0]!.cell_type).toBe('code'); + }); + + it('should handle multiple blocks of different types', async () => { + const mockDeepnoteFile = { + project: { + notebooks: [ + { + id: 'notebook-1', + name: 'Mixed Content', + blocks: [ + { + id: 'block-1', + type: 'code', + source: 'import pandas', + metadata: {} + }, + { + id: 'block-2', + type: 'markdown', + source: '# Analysis', + metadata: {} + }, + { + id: 'block-3', + type: 'code', + source: 'df.head()', + metadata: {} + }, + { + id: 'block-4', + type: 'markdown', + source: 'Results below', + metadata: {} + } + ] + } + ] + } + }; + + (deserializeDeepnoteFile as jest.Mock).mockResolvedValue(mockDeepnoteFile); + + const result = await transformDeepnoteYamlToNotebookContent('yaml-string'); + + expect(result.cells).toHaveLength(4); + const cells = result.cells as any[]; + expect(cells[0].cell_type).toBe('code'); + expect(cells[1].cell_type).toBe('markdown'); + expect(cells[2].cell_type).toBe('code'); + expect(cells[3].cell_type).toBe('markdown'); + }); +}); From 1fbb3d28f595cf9271aaa306d190cb064b33700c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 23 Oct 2025 17:22:26 +0000 Subject: [PATCH 2/7] Address CodeRabbit review comments - Add assertions to verify createPythonCode is called correctly in code cell tests - Add assertions to verify createMarkdown is called correctly in markdown cell tests - Add assertions to verify convertDeepnoteBlockToJupyterCell is called for all notebooks in transform tests - Verify mock call counts and arguments to strengthen test contracts --- ...vert-deepnote-block-to-jupyter-cell.spec.ts | 12 ++++++++++++ ...m-deepnote-yaml-to-notebook-content.spec.ts | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/__tests__/convert-deepnote-block-to-jupyter-cell.spec.ts b/src/__tests__/convert-deepnote-block-to-jupyter-cell.spec.ts index 4cc597f..bda6848 100644 --- a/src/__tests__/convert-deepnote-block-to-jupyter-cell.spec.ts +++ b/src/__tests__/convert-deepnote-block-to-jupyter-cell.spec.ts @@ -51,6 +51,12 @@ describe('convertDeepnoteBlockToJupyterCell', () => { expect(result.source).toBe('print("hello")'); expect(result.execution_count).toBeNull(); expect(result.outputs).toEqual([]); + + const { createPythonCode } = jest.requireMock('@deepnote/blocks'); + expect(createPythonCode).toHaveBeenCalledTimes(1); + expect(createPythonCode).toHaveBeenCalledWith( + expect.objectContaining({ id: 'block-1' }) + ); }); it('should include execution count if present', () => { @@ -194,6 +200,12 @@ describe('convertDeepnoteBlockToJupyterCell', () => { expect(result.cell_type).toBe('markdown'); expect(result.metadata).toEqual({}); expect(result.source).toBe('# Hello'); + + const { createMarkdown } = jest.requireMock('@deepnote/blocks'); + expect(createMarkdown).toHaveBeenCalledTimes(1); + expect(createMarkdown).toHaveBeenCalledWith( + expect.objectContaining({ id: 'block-7' }) + ); }); it('should convert text-cell-h1 to markdown cell', () => { diff --git a/src/__tests__/transform-deepnote-yaml-to-notebook-content.spec.ts b/src/__tests__/transform-deepnote-yaml-to-notebook-content.spec.ts index bf214a3..45ca63a 100644 --- a/src/__tests__/transform-deepnote-yaml-to-notebook-content.spec.ts +++ b/src/__tests__/transform-deepnote-yaml-to-notebook-content.spec.ts @@ -151,6 +151,24 @@ describe('transformDeepnoteYamlToNotebookContent', () => { expect(result.cells).toHaveLength(1); const cells = result.cells as any[]; expect(cells[0].source).toBe('first_notebook_code'); + + const { convertDeepnoteBlockToJupyterCell } = jest.requireMock( + '../convert-deepnote-block-to-jupyter-cell' + ); + expect(convertDeepnoteBlockToJupyterCell).toHaveBeenCalledTimes(3); + const calls = convertDeepnoteBlockToJupyterCell.mock.calls; + expect(calls[0][0]).toMatchObject({ + id: 'block-1', + source: 'first_notebook_code' + }); + expect(calls[1][0]).toMatchObject({ + id: 'block-2', + source: 'second_notebook_code' + }); + expect(calls[2][0]).toMatchObject({ + id: 'block-1', + source: 'first_notebook_code' + }); }); it('should handle empty notebooks gracefully', async () => { From 9dce671a3d5eb80ad39d7b6a29c0bf95d0dbfd7d Mon Sep 17 00:00:00 2001 From: James Hobbs <15235276+jamesbhobbs@users.noreply.github.com> Date: Mon, 27 Oct 2025 07:56:37 +0000 Subject: [PATCH 3/7] fix: Add ESLint rules for type safety and fix violations (#46) * Add ESLint rules for type safety and fix violations - Add @typescript-eslint/no-floating-promises: error - Add @typescript-eslint/no-non-null-assertion: error - Add @typescript-eslint/prefer-nullish-coalescing: error - Change @typescript-eslint/no-explicit-any from off to error Fixes: - Fix floating promise in NotebookPicker constructor by adding .catch() - Fix non-null assertion in NotebookPicker.onAfterAttach with null check - Fix prefer-nullish-coalescing in handler.ts by using ?? instead of || - Add inline eslint-disable comments for legitimate any usage in handler.ts * Add test for error handling in NotebookPicker constructor Improves test coverage for the .catch() error handler added to handle promise rejections in the constructor. * Add comprehensive test coverage for NotebookPicker - Add test for null model in handleChange - Add test for invalid metadata in handleChange - Add test for onAfterAttach without parent - Import Message type for test Coverage improved from 84.09% to 97.72% for NotebookPicker.tsx * Merge test coverage from PR #45 Added comprehensive test suites for core transformation functions: - convert-deepnote-block-to-jupyter-cell.spec.ts (13 tests) - transform-deepnote-yaml-to-notebook-content.spec.ts (11 tests) Overall coverage improved from 31.03% to 57.24% Both transformation files now have 100% line coverage * fix: remove mocks, test real implementation * chore: format * chore: remove out of scope tests * chore: remove out of scope test * refactor: fix test and format * chore: remove out of scope test * fix: address violations * refactor: improve test * refactor: TypeError * fix: drop unnecessary property * refactor: simplify promise * chore: improve "dirty" test --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: dinohamzic --- jest.config.js | 4 +- package.json | 5 +- src/__tests__/NotebookPicker.spec.ts | 337 ++++++++++++++++++++------- src/components/NotebookPicker.tsx | 31 ++- src/handler.ts | 16 +- 5 files changed, 296 insertions(+), 97 deletions(-) diff --git a/jest.config.js b/jest.config.js index db456ae..dba71c0 100644 --- a/jest.config.js +++ b/jest.config.js @@ -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); diff --git a/package.json b/package.json index ba570e5..8c1790e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/__tests__/NotebookPicker.spec.ts b/src/__tests__/NotebookPicker.spec.ts index 4708c5b..fde489e 100644 --- a/src/__tests__/NotebookPicker.spec.ts +++ b/src/__tests__/NotebookPicker.spec.ts @@ -1,111 +1,292 @@ // Copyright (c) Deepnote // Distributed under the terms of the Modified BSD License. -import { NotebookPicker } from '../../src/components/NotebookPicker'; +import type { INotebookModel, NotebookPanel } from '@jupyterlab/notebook'; import { framePromise } from '@jupyterlab/testing'; -import { NotebookPanel } from '@jupyterlab/notebook'; -import { INotebookModel } from '@jupyterlab/notebook'; +import type { PartialJSONObject } from '@lumino/coreutils'; import { Widget } from '@lumino/widgets'; -import { simulate } from 'simulate-event'; +import { NotebookPicker } from '../components/NotebookPicker'; describe('NotebookPicker', () => { - let panel: NotebookPanel; - let model: INotebookModel; + let widget: NotebookPicker; + let mockNotebookModel: Partial; + let deepnoteMetadata: PartialJSONObject; + let consoleErrorSpy: jest.SpyInstance | null = null; - beforeEach(async () => { - // Mock model + metadata - model = { + const createMockPanel = ( + metadata: PartialJSONObject, + dirty = false + ): NotebookPanel => { + deepnoteMetadata = metadata; + + mockNotebookModel = { fromJSON: jest.fn(), - get cells() { - return []; - }, - dirty: true - } as any; + dirty: dirty, + getMetadata: jest.fn((key: string) => { + if (key === 'deepnote') { + return deepnoteMetadata; + } + return undefined; + }) + }; - panel = { + return { context: { ready: Promise.resolve(), - model: { - getMetadata: jest.fn().mockReturnValue({ - notebooks: { - nb1: { id: 'nb1', name: 'nb1', cells: [{ source: 'code' }] }, - nb2: { id: 'nb2', name: 'nb2', cells: [] } - }, - notebook_names: ['nb1', 'nb2'] - }) - } + model: mockNotebookModel as INotebookModel }, - model - } as any; + model: mockNotebookModel as INotebookModel + } as unknown as NotebookPanel; + }; - // Attach to DOM - const widget = new NotebookPicker(panel); - // Override onAfterAttach to avoid errors from this.parent being null - (widget as any).onAfterAttach = jest.fn(); + const attachWidget = async (panel: NotebookPanel): Promise => { + widget = new NotebookPicker(panel); Widget.attach(widget, document.body); + // Wait for widget to attach and render await framePromise(); - }); + // Wait for constructor's async initialization to complete + await Promise.resolve(); + await framePromise(); + }; afterEach(() => { - document.body.innerHTML = ''; - jest.restoreAllMocks(); + if (consoleErrorSpy) { + consoleErrorSpy.mockRestore(); + consoleErrorSpy = null; + } + if (widget && !widget.isDisposed) { + widget.dispose(); + } + // Clean up DOM + const attached = document.querySelectorAll('.jp-ReactWidget'); + attached.forEach(node => { + node.remove(); + }); }); - it('should render a select element', async () => { - await framePromise(); // wait for rendering - 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'); + describe('rendering', () => { + it('should render a select element with notebooks', async () => { + const metadata = { + notebooks: { + 'Notebook 1': { id: 'nb1', name: 'Notebook 1', cells: [] }, + 'Notebook 2': { id: 'nb2', name: 'Notebook 2', cells: [] } + } + }; + + const panel = createMockPanel(metadata); + await attachWidget(panel); + + const select = widget.node.querySelector('select'); + expect(select).not.toBeNull(); + expect(select?.options.length).toBe(2); + expect(select?.options[0]?.value).toBe('Notebook 1'); + expect(select?.options[1]?.value).toBe('Notebook 2'); + }); + + it('should render a placeholder when no notebooks are available', async () => { + const metadata = { + notebooks: {} + }; + + const panel = createMockPanel(metadata); + await attachWidget(panel); + + const select = widget.node.querySelector('select'); + expect(select).not.toBeNull(); + expect(select?.options.length).toBe(1); + expect(select?.options[0]?.value).toBe('-'); + }); + + it('should handle invalid metadata gracefully', async () => { + const metadata = { + notebooks: null + } as PartialJSONObject; + + const panel = createMockPanel(metadata); + await attachWidget(panel); + + const select = widget.node.querySelector('select'); + expect(select).not.toBeNull(); + expect(select?.options.length).toBe(1); + expect(select?.options[0]?.value).toBe('-'); + }); }); - it('should call fromJSON when selecting a notebook', async () => { - const select = document.querySelector('select') as HTMLSelectElement; - simulate(select, 'change', { target: { value: 'nb2' } }); - await framePromise(); - expect(model.fromJSON).toHaveBeenCalledWith( - expect.objectContaining({ - cells: expect.any(Array), - metadata: expect.objectContaining({ - deepnote: expect.objectContaining({ - notebooks: expect.any(Object) - }) + describe('notebook selection', () => { + let panel: NotebookPanel; + + beforeEach(async () => { + const metadata = { + notebooks: { + 'Notebook 1': { + id: 'nb1', + name: 'Notebook 1', + cells: [{ cell_type: 'code', source: 'print(1)' }] + }, + 'Notebook 2': { + id: 'nb2', + name: 'Notebook 2', + cells: [{ cell_type: 'code', source: 'print(2)' }] + } + } + }; + + panel = createMockPanel(metadata); + await attachWidget(panel); + }); + + it('should call fromJSON when selecting a different notebook', async () => { + const select = widget.node.querySelector('select') as HTMLSelectElement; + expect(select).not.toBeNull(); + + select.value = 'Notebook 2'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await framePromise(); + + expect(mockNotebookModel.fromJSON).toHaveBeenCalledTimes(1); + expect(mockNotebookModel.fromJSON).toHaveBeenCalledWith( + expect.objectContaining({ + cells: [{ cell_type: 'code', source: 'print(2)' }], + metadata: { + deepnote: { + notebooks: expect.objectContaining({ + 'Notebook 1': expect.any(Object), + 'Notebook 2': expect.any(Object) + }) + } + }, + nbformat: 4, + nbformat_minor: 0 }) - }) - ); - }); + ); + }); - it('should not call fromJSON if selected notebook is invalid', async () => { - const getMetadata = panel.context.model.getMetadata as jest.Mock; - getMetadata.mockReturnValue({ notebooks: {}, notebook_names: [] }); + it('should set model.dirty to false after switching notebooks', async () => { + widget.dispose(); - const select = document.querySelector('select') as HTMLSelectElement; - simulate(select, 'change', { target: { value: 'nonexistent' } }); - await framePromise(); - expect(model.fromJSON).not.toHaveBeenCalled(); + const metadata = { + notebooks: { + 'Notebook 1': { + id: 'nb1', + name: 'Notebook 1', + cells: [{ cell_type: 'code', source: 'print(1)' }] + }, + 'Notebook 2': { + id: 'nb2', + name: 'Notebook 2', + cells: [{ cell_type: 'code', source: 'print(2)' }] + } + } + }; + + const dirtyPanel = createMockPanel(metadata, true); + await attachWidget(dirtyPanel); + + const select = widget.node.querySelector('select') as HTMLSelectElement; + select.value = 'Notebook 2'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await framePromise(); + + expect(mockNotebookModel.dirty).toBe(false); + }); + + it('should not call fromJSON when selecting a non-existent notebook', async () => { + const select = widget.node.querySelector('select') as HTMLSelectElement; + select.value = 'NonExistent'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await framePromise(); + + expect(mockNotebookModel.fromJSON).not.toHaveBeenCalled(); + }); + + it('should not call fromJSON when panel.model is null', async () => { + widget.dispose(); + + // Create panel with null model + const nullModelPanel = { + context: { + ready: Promise.resolve(), + model: { + getMetadata: jest.fn(() => deepnoteMetadata) + } + }, + model: null + } as unknown as NotebookPanel; + + await attachWidget(nullModelPanel); + + const select = widget.node.querySelector('select') as HTMLSelectElement; + select.value = 'Notebook 2'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await framePromise(); + + expect(mockNotebookModel.fromJSON).not.toHaveBeenCalled(); + }); }); - it('should update UI after selection', async () => { - const select = document.querySelector('select') as HTMLSelectElement; - select.value = 'nb2'; - simulate(select, 'change'); - await framePromise(); - expect(select.value).toBe('nb2'); + describe('initialization', () => { + it('should select first notebook by default when notebooks exist', async () => { + const metadata = { + notebooks: { + First: { id: 'nb1', name: 'First', cells: [] }, + Second: { id: 'nb2', name: 'Second', cells: [] } + } + }; + + const panel = createMockPanel(metadata); + await attachWidget(panel); + + const select = widget.node.querySelector('select') as HTMLSelectElement; + expect(select.value).toBe('First'); + }); + + it('should handle initialization errors gracefully', async () => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const failingPanel = { + context: { + ready: Promise.reject(new Error('Initialization failed')), + model: { + getMetadata: jest.fn(() => ({})) + } + }, + model: mockNotebookModel as INotebookModel + } as unknown as NotebookPanel; + + await attachWidget(failingPanel); + await framePromise(); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to initialize NotebookPicker:', + expect.any(Error) + ); + }); }); - it('should handle empty metadata gracefully', async () => { - const getMetadata = panel.context.model.getMetadata as jest.Mock; - getMetadata.mockReturnValue({ notebooks: {}, notebook_names: [] }); + describe('metadata validation', () => { + it('should handle invalid metadata when changing notebooks', async () => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); - document.body.innerHTML = ''; - const widget = new NotebookPicker(panel); - // Override onAfterAttach to avoid errors from this.parent being null - (widget as any).onAfterAttach = jest.fn(); - Widget.attach(widget, document.body); - await framePromise(); + const metadata = { + notebooks: { + 'Notebook 1': { id: 'nb1', name: 'Notebook 1', cells: [] } + } + }; + + const panel = createMockPanel(metadata); + await attachWidget(panel); + + // Change the metadata to invalid format + deepnoteMetadata = { invalid: 'metadata' } as PartialJSONObject; + + const select = widget.node.querySelector('select') as HTMLSelectElement; + select.value = 'Notebook 1'; + select.dispatchEvent(new Event('change', { bubbles: true })); + 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(consoleErrorSpy).toHaveBeenCalled(); + expect(consoleErrorSpy.mock.calls[0]?.[0]).toMatch(/invalid.*metadata/i); + expect(mockNotebookModel.fromJSON).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/components/NotebookPicker.tsx b/src/components/NotebookPicker.tsx index 1dbb941..102f7e3 100644 --- a/src/components/NotebookPicker.tsx +++ b/src/components/NotebookPicker.tsx @@ -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) => { @@ -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); + } }); } diff --git a/src/handler.ts b/src/handler.ts index d4c619b..4e2bf1d 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -25,21 +25,27 @@ export async function requestAPI( try { response = await ServerConnection.makeRequest(requestUrl, init, settings); } catch (error) { - throw new ServerConnection.NetworkError(error as any); + throw new ServerConnection.NetworkError( + error instanceof TypeError ? error : new TypeError(String(error)) + ); } - let data: any = await response.text(); + let data: string | unknown = await response.text(); - if (data.length > 0) { + if (typeof data === 'string' && data.length > 0) { try { data = JSON.parse(data); - } catch (error) { + } catch { console.log('Not a JSON response body.', response); } } if (!response.ok) { - throw new ServerConnection.ResponseError(response, data.message || data); + const errorMessage = + data && typeof data === 'object' && 'message' in data + ? (data as { message: string }).message + : String(data); + throw new ServerConnection.ResponseError(response, errorMessage); } return data; From f346f3eb533c4635e3c868e02a1dddc25a30a0c6 Mon Sep 17 00:00:00 2001 From: dinohamzic Date: Mon, 27 Oct 2025 09:01:07 +0100 Subject: [PATCH 4/7] chore: improve test, avoid unnecessary mocks --- ...ert-deepnote-block-to-jupyter-cell.spec.ts | 168 +++++++----------- 1 file changed, 60 insertions(+), 108 deletions(-) diff --git a/src/__tests__/convert-deepnote-block-to-jupyter-cell.spec.ts b/src/__tests__/convert-deepnote-block-to-jupyter-cell.spec.ts index bda6848..114cc26 100644 --- a/src/__tests__/convert-deepnote-block-to-jupyter-cell.spec.ts +++ b/src/__tests__/convert-deepnote-block-to-jupyter-cell.spec.ts @@ -1,38 +1,8 @@ // Copyright (c) Deepnote // Distributed under the terms of the Modified BSD License. +import type { DeepnoteBlock } from '@deepnote/blocks'; import { convertDeepnoteBlockToJupyterCell } from '../convert-deepnote-block-to-jupyter-cell'; -import { DeepnoteBlock } from '@deepnote/blocks'; - -jest.mock('@deepnote/blocks', () => ({ - createPythonCode: jest.fn((block: any) => block.source || 'print("test")'), - createMarkdown: jest.fn((block: any) => block.source || '# Test') -})); - -jest.mock('../convert-deepnote-block-type-to-jupyter', () => ({ - convertDeepnoteBlockTypeToJupyter: jest.fn((type: string) => { - if ( - [ - 'code', - 'sql', - 'notebook-function', - 'big-number', - 'visualization', - 'input-text', - 'input-checkbox', - 'input-textarea', - 'input-file', - 'input-select', - 'input-date-range', - 'input-date', - 'input-slider' - ].includes(type) - ) { - return 'code'; - } - return 'markdown'; - }) -})); describe('convertDeepnoteBlockToJupyterCell', () => { describe('code cells', () => { @@ -40,9 +10,10 @@ describe('convertDeepnoteBlockToJupyterCell', () => { const block: DeepnoteBlock = { id: 'block-1', type: 'code', - source: 'print("hello")', - metadata: { foo: 'bar' } - } as any; + content: 'print("hello")', + metadata: { foo: 'bar' }, + sortingKey: '1' + }; const result = convertDeepnoteBlockToJupyterCell(block); @@ -51,22 +22,17 @@ describe('convertDeepnoteBlockToJupyterCell', () => { expect(result.source).toBe('print("hello")'); expect(result.execution_count).toBeNull(); expect(result.outputs).toEqual([]); - - const { createPythonCode } = jest.requireMock('@deepnote/blocks'); - expect(createPythonCode).toHaveBeenCalledTimes(1); - expect(createPythonCode).toHaveBeenCalledWith( - expect.objectContaining({ id: 'block-1' }) - ); }); it('should include execution count if present', () => { const block: DeepnoteBlock = { id: 'block-2', type: 'code', - source: 'x = 1', + content: 'x = 1', metadata: {}, - executionCount: 5 - } as any; + executionCount: 5, + sortingKey: '1' + }; const result = convertDeepnoteBlockToJupyterCell(block); @@ -86,10 +52,11 @@ describe('convertDeepnoteBlockToJupyterCell', () => { const block: DeepnoteBlock = { id: 'block-3', type: 'code', - source: 'print("hello")', + content: 'print("hello")', metadata: {}, - outputs: blockOutputs - } as any; + outputs: blockOutputs, + sortingKey: '1' + }; const result = convertDeepnoteBlockToJupyterCell(block); @@ -110,16 +77,21 @@ describe('convertDeepnoteBlockToJupyterCell', () => { const block: DeepnoteBlock = { id: 'block-4', type: 'code', - source: 'print("hello")', + content: 'print("hello")', metadata: {}, - outputs: blockOutputs - } as any; + outputs: blockOutputs, + sortingKey: '1' + }; const result = convertDeepnoteBlockToJupyterCell(block); expect(result.cell_type).toBe('code'); expect(result.outputs).toHaveLength(1); - const resultOutputs = result.outputs as any[]; + const resultOutputs = result.outputs as Array<{ + output_type: string; + name: string; + text: string; + }>; expect(resultOutputs[0]).not.toHaveProperty('truncated'); expect(resultOutputs[0]).toEqual({ output_type: 'stream', @@ -147,16 +119,21 @@ describe('convertDeepnoteBlockToJupyterCell', () => { const block: DeepnoteBlock = { id: 'block-5', type: 'code', - source: 'print("test")', + content: 'print("test")', metadata: {}, - outputs: blockOutputs - } as any; + outputs: blockOutputs, + sortingKey: '1' + }; const result = convertDeepnoteBlockToJupyterCell(block); expect(result.cell_type).toBe('code'); expect(result.outputs).toHaveLength(2); - const resultOutputs = result.outputs as any[]; + const resultOutputs = result.outputs as Array<{ + output_type: string; + name: string; + text: string; + }>; expect(resultOutputs[0]).not.toHaveProperty('truncated'); expect(resultOutputs[1]).not.toHaveProperty('truncated'); }); @@ -174,14 +151,15 @@ describe('convertDeepnoteBlockToJupyterCell', () => { const block: DeepnoteBlock = { id: 'block-6', type: 'code', - source: 'print("hello")', + content: 'print("hello")', metadata: { test: 'value' }, - outputs: blockOutputs - } as any; + outputs: blockOutputs, + sortingKey: '1' + }; convertDeepnoteBlockToJupyterCell(block); - expect(block.outputs![0]).toHaveProperty('truncated'); + expect(block.outputs?.[0]).toHaveProperty('truncated'); expect(block.metadata).toEqual({ test: 'value' }); }); }); @@ -191,30 +169,26 @@ describe('convertDeepnoteBlockToJupyterCell', () => { const block: DeepnoteBlock = { id: 'block-7', type: 'markdown', - source: '# Hello', - metadata: { foo: 'bar' } - } as any; + content: '# Hello', + metadata: { foo: 'bar' }, + sortingKey: '1' + }; const result = convertDeepnoteBlockToJupyterCell(block); expect(result.cell_type).toBe('markdown'); expect(result.metadata).toEqual({}); expect(result.source).toBe('# Hello'); - - const { createMarkdown } = jest.requireMock('@deepnote/blocks'); - expect(createMarkdown).toHaveBeenCalledTimes(1); - expect(createMarkdown).toHaveBeenCalledWith( - expect.objectContaining({ id: 'block-7' }) - ); }); it('should convert text-cell-h1 to markdown cell', () => { const block: DeepnoteBlock = { id: 'block-8', type: 'text-cell-h1', - source: 'Heading 1', - metadata: {} - } as any; + content: 'Heading 1', + metadata: {}, + sortingKey: '1' + }; const result = convertDeepnoteBlockToJupyterCell(block); @@ -225,9 +199,10 @@ describe('convertDeepnoteBlockToJupyterCell', () => { const block: DeepnoteBlock = { id: 'block-9', type: 'image', - source: '![alt](url)', - metadata: {} - } as any; + content: '![alt](url)', + metadata: {}, + sortingKey: '1' + }; const result = convertDeepnoteBlockToJupyterCell(block); @@ -238,9 +213,10 @@ describe('convertDeepnoteBlockToJupyterCell', () => { const block: DeepnoteBlock = { id: 'block-10', type: 'markdown', - source: 'Text', - metadata: { deepnoteMetadata: 'should not appear' } - } as any; + content: 'Text', + metadata: { deepnoteMetadata: 'should not appear' }, + sortingKey: '1' + }; const result = convertDeepnoteBlockToJupyterCell(block); @@ -254,9 +230,10 @@ describe('convertDeepnoteBlockToJupyterCell', () => { const block: DeepnoteBlock = { id: 'block-11', type: 'sql', - source: 'SELECT * FROM table', - metadata: {} - } as any; + content: 'SELECT * FROM table', + metadata: {}, + sortingKey: '1' + }; const result = convertDeepnoteBlockToJupyterCell(block); @@ -267,39 +244,14 @@ describe('convertDeepnoteBlockToJupyterCell', () => { const block: DeepnoteBlock = { id: 'block-12', type: 'visualization', - source: 'chart_data', - metadata: {} - } as any; + content: 'chart_data', + metadata: {}, + sortingKey: '1' + }; const result = convertDeepnoteBlockToJupyterCell(block); expect(result.cell_type).toBe('code'); }); - - it('should convert input blocks to code cells', () => { - const inputTypes = [ - 'input-text', - 'input-checkbox', - 'input-textarea', - 'input-file', - 'input-select', - 'input-date-range', - 'input-date', - 'input-slider' - ]; - - inputTypes.forEach(type => { - const block: DeepnoteBlock = { - id: `block-${type}`, - type, - source: 'input_value', - metadata: {} - } as any; - - const result = convertDeepnoteBlockToJupyterCell(block); - - expect(result.cell_type).toBe('code'); - }); - }); }); }); From 305fe5a43accb313d314ed9f769f40dcca91806e Mon Sep 17 00:00:00 2001 From: dinohamzic Date: Mon, 27 Oct 2025 09:03:55 +0100 Subject: [PATCH 5/7] chore: remove copyright comment --- src/__tests__/NotebookPicker.spec.ts | 3 --- src/__tests__/convert-deepnote-block-to-jupyter-cell.spec.ts | 3 --- src/__tests__/convert-deepnote-block-type-to-jupyter.spec.ts | 3 --- .../transform-deepnote-yaml-to-notebook-content.spec.ts | 3 --- 4 files changed, 12 deletions(-) diff --git a/src/__tests__/NotebookPicker.spec.ts b/src/__tests__/NotebookPicker.spec.ts index fde489e..08d111c 100644 --- a/src/__tests__/NotebookPicker.spec.ts +++ b/src/__tests__/NotebookPicker.spec.ts @@ -1,6 +1,3 @@ -// Copyright (c) Deepnote -// Distributed under the terms of the Modified BSD License. - import type { INotebookModel, NotebookPanel } from '@jupyterlab/notebook'; import { framePromise } from '@jupyterlab/testing'; import type { PartialJSONObject } from '@lumino/coreutils'; diff --git a/src/__tests__/convert-deepnote-block-to-jupyter-cell.spec.ts b/src/__tests__/convert-deepnote-block-to-jupyter-cell.spec.ts index 114cc26..47c329e 100644 --- a/src/__tests__/convert-deepnote-block-to-jupyter-cell.spec.ts +++ b/src/__tests__/convert-deepnote-block-to-jupyter-cell.spec.ts @@ -1,6 +1,3 @@ -// Copyright (c) Deepnote -// Distributed under the terms of the Modified BSD License. - import type { DeepnoteBlock } from '@deepnote/blocks'; import { convertDeepnoteBlockToJupyterCell } from '../convert-deepnote-block-to-jupyter-cell'; diff --git a/src/__tests__/convert-deepnote-block-type-to-jupyter.spec.ts b/src/__tests__/convert-deepnote-block-type-to-jupyter.spec.ts index 537584d..3797bff 100644 --- a/src/__tests__/convert-deepnote-block-type-to-jupyter.spec.ts +++ b/src/__tests__/convert-deepnote-block-type-to-jupyter.spec.ts @@ -1,6 +1,3 @@ -// Copyright (c) Deepnote -// Distributed under the terms of the Modified BSD License. - import { convertDeepnoteBlockTypeToJupyter } from '../convert-deepnote-block-type-to-jupyter'; describe('convertDeepnoteBlockTypeToJupyter', () => { diff --git a/src/__tests__/transform-deepnote-yaml-to-notebook-content.spec.ts b/src/__tests__/transform-deepnote-yaml-to-notebook-content.spec.ts index 45ca63a..54ae8ec 100644 --- a/src/__tests__/transform-deepnote-yaml-to-notebook-content.spec.ts +++ b/src/__tests__/transform-deepnote-yaml-to-notebook-content.spec.ts @@ -1,6 +1,3 @@ -// Copyright (c) Deepnote -// Distributed under the terms of the Modified BSD License. - import { transformDeepnoteYamlToNotebookContent } from '../transform-deepnote-yaml-to-notebook-content'; import { deserializeDeepnoteFile } from '@deepnote/blocks'; From 9f0360dad6f18f4af802c025120257e8a72416a0 Mon Sep 17 00:00:00 2001 From: dinohamzic Date: Mon, 27 Oct 2025 09:32:15 +0100 Subject: [PATCH 6/7] chore: improve test by avoiding any mocking --- ...-deepnote-yaml-to-notebook-content.spec.ts | 509 +++++++++--------- 1 file changed, 243 insertions(+), 266 deletions(-) diff --git a/src/__tests__/transform-deepnote-yaml-to-notebook-content.spec.ts b/src/__tests__/transform-deepnote-yaml-to-notebook-content.spec.ts index 54ae8ec..1374aee 100644 --- a/src/__tests__/transform-deepnote-yaml-to-notebook-content.spec.ts +++ b/src/__tests__/transform-deepnote-yaml-to-notebook-content.spec.ts @@ -1,227 +1,202 @@ +import type { ICell } from '@jupyterlab/nbformat'; import { transformDeepnoteYamlToNotebookContent } from '../transform-deepnote-yaml-to-notebook-content'; -import { deserializeDeepnoteFile } from '@deepnote/blocks'; - -jest.mock('@deepnote/blocks', () => ({ - deserializeDeepnoteFile: jest.fn() -})); - -jest.mock('../convert-deepnote-block-to-jupyter-cell', () => ({ - convertDeepnoteBlockToJupyterCell: jest.fn(block => ({ - cell_type: block.type === 'code' ? 'code' : 'markdown', - source: block.source || '', - metadata: block.type === 'code' ? { cell_id: block.id } : {}, - ...(block.type === 'code' && { - execution_count: block.executionCount || null, - outputs: block.outputs || [] - }) - })) -})); describe('transformDeepnoteYamlToNotebookContent', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - it('should transform a simple Deepnote YAML to notebook content', async () => { - const mockDeepnoteFile = { - project: { - notebooks: [ - { - id: 'notebook-1', - name: 'Main Notebook', - blocks: [ - { - id: 'block-1', - type: 'code', - source: 'print("hello")', - metadata: {} - }, - { - id: 'block-2', - type: 'markdown', - source: '# Title', - metadata: {} - } - ] - } - ] - } - }; - - (deserializeDeepnoteFile as jest.Mock).mockResolvedValue(mockDeepnoteFile); - - const result = await transformDeepnoteYamlToNotebookContent('yaml-string'); + const yamlString = ` +metadata: + createdAt: 2025-04-30T14:02:50.919Z + modifiedAt: 2025-09-05T11:05:19.666Z +project: + id: test-project-id + name: Test Project + notebooks: + - blocks: + - content: print("hello") + id: block-1 + metadata: {} + sortingKey: "1" + type: code + - content: "# Title" + id: block-2 + metadata: {} + sortingKey: "2" + type: markdown + executionMode: block + id: notebook-1 + isModule: false + name: Main Notebook + settings: {} +version: 1.0.0 +`; + + const result = await transformDeepnoteYamlToNotebookContent(yamlString); expect(result.cells).toHaveLength(2); - const cells = result.cells as any[]; - expect(cells[0].cell_type).toBe('code'); - expect(cells[1].cell_type).toBe('markdown'); + const cells = result.cells as ICell[]; + expect(cells[0]).toHaveProperty('cell_type', 'code'); + expect(cells[1]).toHaveProperty('cell_type', 'markdown'); expect(result.metadata.deepnote.notebooks).toHaveProperty('Main Notebook'); expect(result.nbformat).toBe(4); expect(result.nbformat_minor).toBe(0); }); it('should include metadata for all notebooks', async () => { - const mockDeepnoteFile = { - project: { - notebooks: [ - { - id: 'notebook-1', - name: 'First Notebook', - blocks: [ - { - id: 'block-1', - type: 'code', - source: 'x = 1', - metadata: {} - } - ] - }, - { - id: 'notebook-2', - name: 'Second Notebook', - blocks: [ - { - id: 'block-2', - type: 'markdown', - source: '# Second', - metadata: {} - } - ] - } - ] - } - }; - - (deserializeDeepnoteFile as jest.Mock).mockResolvedValue(mockDeepnoteFile); - - const result = await transformDeepnoteYamlToNotebookContent('yaml-string'); + const yamlString = ` +metadata: + createdAt: 2025-04-30T14:02:50.919Z + modifiedAt: 2025-09-05T11:05:19.666Z +project: + id: test-project-id + name: Test Project + notebooks: + - blocks: + - content: x = 1 + id: block-1 + metadata: {} + sortingKey: "1" + type: code + executionMode: block + id: notebook-1 + isModule: false + name: First Notebook + - blocks: + - content: "# Second" + id: block-2 + metadata: {} + sortingKey: "1" + type: markdown + executionMode: block + id: notebook-2 + isModule: false + name: Second Notebook + settings: {} +version: 1.0.0 +`; + + const result = await transformDeepnoteYamlToNotebookContent(yamlString); expect(result.metadata.deepnote.notebooks).toHaveProperty('First Notebook'); expect(result.metadata.deepnote.notebooks).toHaveProperty( 'Second Notebook' ); - expect(result.metadata.deepnote.notebooks['First Notebook']!.id).toBe( - 'notebook-1' - ); - expect(result.metadata.deepnote.notebooks['Second Notebook']!.id).toBe( - 'notebook-2' - ); + const firstNotebook = result.metadata.deepnote.notebooks['First Notebook']; + expect(firstNotebook).toBeDefined(); + expect(firstNotebook?.id).toBe('notebook-1'); + const secondNotebook = + result.metadata.deepnote.notebooks['Second Notebook']; + expect(secondNotebook).toBeDefined(); + expect(secondNotebook?.id).toBe('notebook-2'); }); it('should use the first notebook for primary cell content', async () => { - const mockDeepnoteFile = { - project: { - notebooks: [ - { - id: 'notebook-1', - name: 'First', - blocks: [ - { - id: 'block-1', - type: 'code', - source: 'first_notebook_code', - metadata: {} - } - ] - }, - { - id: 'notebook-2', - name: 'Second', - blocks: [ - { - id: 'block-2', - type: 'code', - source: 'second_notebook_code', - metadata: {} - } - ] - } - ] - } - }; - - (deserializeDeepnoteFile as jest.Mock).mockResolvedValue(mockDeepnoteFile); - - const result = await transformDeepnoteYamlToNotebookContent('yaml-string'); + const yamlString = ` +metadata: + createdAt: 2025-04-30T14:02:50.919Z + modifiedAt: 2025-09-05T11:05:19.666Z +project: + id: test-project-id + name: Test Project + notebooks: + - blocks: + - content: first_notebook_code + id: block-1 + metadata: {} + sortingKey: "1" + type: code + executionMode: block + id: notebook-1 + isModule: false + name: First + - blocks: + - content: second_notebook_code + id: block-2 + metadata: {} + sortingKey: "1" + type: code + executionMode: block + id: notebook-2 + isModule: false + name: Second + settings: {} +version: 1.0.0 +`; + + const result = await transformDeepnoteYamlToNotebookContent(yamlString); expect(result.cells).toHaveLength(1); - const cells = result.cells as any[]; - expect(cells[0].source).toBe('first_notebook_code'); - - const { convertDeepnoteBlockToJupyterCell } = jest.requireMock( - '../convert-deepnote-block-to-jupyter-cell' - ); - expect(convertDeepnoteBlockToJupyterCell).toHaveBeenCalledTimes(3); - const calls = convertDeepnoteBlockToJupyterCell.mock.calls; - expect(calls[0][0]).toMatchObject({ - id: 'block-1', - source: 'first_notebook_code' - }); - expect(calls[1][0]).toMatchObject({ - id: 'block-2', - source: 'second_notebook_code' - }); - expect(calls[2][0]).toMatchObject({ - id: 'block-1', - source: 'first_notebook_code' - }); + const cells = result.cells as ICell[]; + expect(cells[0]).toHaveProperty('source', 'first_notebook_code'); }); it('should handle empty notebooks gracefully', async () => { - const mockDeepnoteFile = { - project: { - notebooks: [ - { - id: 'notebook-1', - name: 'Empty Notebook', - blocks: [] - } - ] - } - }; - - (deserializeDeepnoteFile as jest.Mock).mockResolvedValue(mockDeepnoteFile); - - const result = await transformDeepnoteYamlToNotebookContent('yaml-string'); + const yamlString = ` +metadata: + createdAt: 2025-04-30T14:02:50.919Z + modifiedAt: 2025-09-05T11:05:19.666Z +project: + id: test-project-id + name: Test Project + notebooks: + - blocks: [] + executionMode: block + id: notebook-1 + isModule: false + name: Empty Notebook + settings: {} +version: 1.0.0 +`; + + const result = await transformDeepnoteYamlToNotebookContent(yamlString); expect(result.cells).toHaveLength(0); expect(result.metadata.deepnote.notebooks).toHaveProperty('Empty Notebook'); }); it('should handle file with no notebooks', async () => { - const mockDeepnoteFile = { - project: { - notebooks: [] - } - }; - - (deserializeDeepnoteFile as jest.Mock).mockResolvedValue(mockDeepnoteFile); - - const result = await transformDeepnoteYamlToNotebookContent('yaml-string'); + const yamlString = ` +metadata: + createdAt: 2025-04-30T14:02:50.919Z + modifiedAt: 2025-09-05T11:05:19.666Z +project: + id: test-project-id + name: Test Project + notebooks: [] + settings: {} +version: 1.0.0 +`; + + const result = await transformDeepnoteYamlToNotebookContent(yamlString); expect(result.cells).toHaveLength(1); - const cells = result.cells as any[]; - expect(cells[0].cell_type).toBe('code'); - expect(cells[0].source).toContain('No notebooks found'); + const cells = result.cells as ICell[]; + const firstCell = cells[0]; + expect(firstCell).toBeDefined(); + expect(firstCell?.cell_type).toBe('code'); + const source = firstCell?.source; + expect(source).toBeDefined(); + expect(source).toContain('No notebooks found'); }); it('should include kernel metadata', async () => { - const mockDeepnoteFile = { - project: { - notebooks: [ - { - id: 'notebook-1', - name: 'Test', - blocks: [] - } - ] - } - }; - - (deserializeDeepnoteFile as jest.Mock).mockResolvedValue(mockDeepnoteFile); - - const result = await transformDeepnoteYamlToNotebookContent('yaml-string'); + const yamlString = ` +metadata: + createdAt: 2025-04-30T14:02:50.919Z + modifiedAt: 2025-09-05T11:05:19.666Z +project: + id: test-project-id + name: Test Project + notebooks: + - blocks: [] + executionMode: block + id: notebook-1 + isModule: false + name: Test + settings: {} +version: 1.0.0 +`; + + const result = await transformDeepnoteYamlToNotebookContent(yamlString); expect(result.metadata).toHaveProperty('kernelspec'); expect(result.metadata).toHaveProperty('language_info'); @@ -230,96 +205,98 @@ describe('transformDeepnoteYamlToNotebookContent', () => { }); it('should throw error when deserialization fails', async () => { - (deserializeDeepnoteFile as jest.Mock).mockRejectedValue( - new Error('Invalid YAML') - ); + const invalidYaml = 'this is not valid yaml: {{{'; await expect( - transformDeepnoteYamlToNotebookContent('invalid-yaml') + transformDeepnoteYamlToNotebookContent(invalidYaml) ).rejects.toThrow('Failed to transform Deepnote YAML to notebook content.'); }); it('should preserve notebook structure in metadata', async () => { - const mockDeepnoteFile = { - project: { - notebooks: [ - { - id: 'notebook-1', - name: 'Test Notebook', - blocks: [ - { - id: 'block-1', - type: 'code', - source: 'x = 1', - metadata: {}, - executionCount: 5, - outputs: [{ output_type: 'stream', text: 'output' }] - } - ] - } - ] - } - }; - - (deserializeDeepnoteFile as jest.Mock).mockResolvedValue(mockDeepnoteFile); - - const result = await transformDeepnoteYamlToNotebookContent('yaml-string'); + const yamlString = ` +metadata: + createdAt: 2025-04-30T14:02:50.919Z + modifiedAt: 2025-09-05T11:05:19.666Z +project: + id: test-project-id + name: Test Project + notebooks: + - blocks: + - content: x = 1 + id: block-1 + metadata: {} + executionCount: 5 + outputs: + - output_type: stream + text: output + sortingKey: "1" + type: code + executionMode: block + id: notebook-1 + isModule: false + name: Test Notebook + settings: {} +version: 1.0.0 +`; + + const result = await transformDeepnoteYamlToNotebookContent(yamlString); const notebookMetadata = - result.metadata.deepnote.notebooks['Test Notebook']!; - expect(notebookMetadata.id).toBe('notebook-1'); - expect(notebookMetadata.name).toBe('Test Notebook'); - expect(notebookMetadata.cells).toHaveLength(1); - expect(notebookMetadata.cells[0]!.cell_type).toBe('code'); + result.metadata.deepnote.notebooks['Test Notebook']; + expect(notebookMetadata).toBeDefined(); + expect(notebookMetadata?.id).toBe('notebook-1'); + expect(notebookMetadata?.name).toBe('Test Notebook'); + expect(notebookMetadata?.cells).toHaveLength(1); + const firstCell = notebookMetadata?.cells[0]; + expect(firstCell).toBeDefined(); + expect(firstCell?.cell_type).toBe('code'); }); it('should handle multiple blocks of different types', async () => { - const mockDeepnoteFile = { - project: { - notebooks: [ - { - id: 'notebook-1', - name: 'Mixed Content', - blocks: [ - { - id: 'block-1', - type: 'code', - source: 'import pandas', - metadata: {} - }, - { - id: 'block-2', - type: 'markdown', - source: '# Analysis', - metadata: {} - }, - { - id: 'block-3', - type: 'code', - source: 'df.head()', - metadata: {} - }, - { - id: 'block-4', - type: 'markdown', - source: 'Results below', - metadata: {} - } - ] - } - ] - } - }; - - (deserializeDeepnoteFile as jest.Mock).mockResolvedValue(mockDeepnoteFile); - - const result = await transformDeepnoteYamlToNotebookContent('yaml-string'); + const yamlString = ` +metadata: + createdAt: 2025-04-30T14:02:50.919Z + modifiedAt: 2025-09-05T11:05:19.666Z +project: + id: test-project-id + name: Test Project + notebooks: + - blocks: + - content: import pandas + id: block-1 + metadata: {} + sortingKey: "1" + type: code + - content: "# Analysis" + id: block-2 + metadata: {} + sortingKey: "2" + type: markdown + - content: df.head() + id: block-3 + metadata: {} + sortingKey: "3" + type: code + - content: Results below + id: block-4 + metadata: {} + sortingKey: "4" + type: markdown + executionMode: block + id: notebook-1 + isModule: false + name: Mixed Content + settings: {} +version: 1.0.0 +`; + + const result = await transformDeepnoteYamlToNotebookContent(yamlString); expect(result.cells).toHaveLength(4); - const cells = result.cells as any[]; - expect(cells[0].cell_type).toBe('code'); - expect(cells[1].cell_type).toBe('markdown'); - expect(cells[2].cell_type).toBe('code'); - expect(cells[3].cell_type).toBe('markdown'); + const cells = result.cells as ICell[]; + expect(cells[0]).toHaveProperty('cell_type', 'code'); + expect(cells[1]).toHaveProperty('cell_type', 'markdown'); + expect(cells[2]).toHaveProperty('cell_type', 'code'); + expect(cells[3]).toHaveProperty('cell_type', 'markdown'); }); }); From 915f9fd0231a6e2ac1a69a16d45416d0c4b65939 Mon Sep 17 00:00:00 2001 From: dinohamzic Date: Mon, 27 Oct 2025 10:11:40 +0100 Subject: [PATCH 7/7] chore: new line --- src/__tests__/NotebookPicker.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/NotebookPicker.spec.ts b/src/__tests__/NotebookPicker.spec.ts index 1a5f8d9..08d111c 100644 --- a/src/__tests__/NotebookPicker.spec.ts +++ b/src/__tests__/NotebookPicker.spec.ts @@ -286,4 +286,4 @@ describe('NotebookPicker', () => { expect(mockNotebookModel.fromJSON).not.toHaveBeenCalled(); }); }); -}); \ No newline at end of file +});