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 new file mode 100644 index 0000000..47c329e --- /dev/null +++ b/src/__tests__/convert-deepnote-block-to-jupyter-cell.spec.ts @@ -0,0 +1,254 @@ +import type { DeepnoteBlock } from '@deepnote/blocks'; +import { convertDeepnoteBlockToJupyterCell } from '../convert-deepnote-block-to-jupyter-cell'; + +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', + content: 'print("hello")', + metadata: { foo: 'bar' }, + sortingKey: '1' + }; + + 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', + content: 'x = 1', + metadata: {}, + executionCount: 5, + sortingKey: '1' + }; + + 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', + content: 'print("hello")', + metadata: {}, + outputs: blockOutputs, + sortingKey: '1' + }; + + 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', + content: 'print("hello")', + metadata: {}, + outputs: blockOutputs, + sortingKey: '1' + }; + + const result = convertDeepnoteBlockToJupyterCell(block); + + expect(result.cell_type).toBe('code'); + expect(result.outputs).toHaveLength(1); + 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', + 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', + content: 'print("test")', + metadata: {}, + outputs: blockOutputs, + sortingKey: '1' + }; + + const result = convertDeepnoteBlockToJupyterCell(block); + + expect(result.cell_type).toBe('code'); + expect(result.outputs).toHaveLength(2); + 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'); + }); + + 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', + content: 'print("hello")', + metadata: { test: 'value' }, + outputs: blockOutputs, + sortingKey: '1' + }; + + 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', + 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'); + }); + + it('should convert text-cell-h1 to markdown cell', () => { + const block: DeepnoteBlock = { + id: 'block-8', + type: 'text-cell-h1', + content: 'Heading 1', + metadata: {}, + sortingKey: '1' + }; + + 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', + content: '![alt](url)', + metadata: {}, + sortingKey: '1' + }; + + 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', + content: 'Text', + metadata: { deepnoteMetadata: 'should not appear' }, + sortingKey: '1' + }; + + 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', + content: 'SELECT * FROM table', + metadata: {}, + sortingKey: '1' + }; + + 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', + content: 'chart_data', + metadata: {}, + sortingKey: '1' + }; + + 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..3797bff --- /dev/null +++ b/src/__tests__/convert-deepnote-block-type-to-jupyter.spec.ts @@ -0,0 +1,141 @@ +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..1374aee --- /dev/null +++ b/src/__tests__/transform-deepnote-yaml-to-notebook-content.spec.ts @@ -0,0 +1,302 @@ +import type { ICell } from '@jupyterlab/nbformat'; +import { transformDeepnoteYamlToNotebookContent } from '../transform-deepnote-yaml-to-notebook-content'; + +describe('transformDeepnoteYamlToNotebookContent', () => { + it('should transform a simple Deepnote YAML to notebook content', async () => { + 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 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 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' + ); + 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 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 ICell[]; + expect(cells[0]).toHaveProperty('source', 'first_notebook_code'); + }); + + it('should handle empty notebooks gracefully', async () => { + 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 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 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 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'); + expect(result.metadata.kernelspec).toHaveProperty('name', 'python3'); + expect(result.metadata.language_info).toHaveProperty('name', 'python'); + }); + + it('should throw error when deserialization fails', async () => { + const invalidYaml = 'this is not valid yaml: {{{'; + + await expect( + transformDeepnoteYamlToNotebookContent(invalidYaml) + ).rejects.toThrow('Failed to transform Deepnote YAML to notebook content.'); + }); + + it('should preserve notebook structure in metadata', async () => { + 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).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 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 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'); + }); +});