Path: blob/main/extensions/ipynb/src/test/serializers.test.ts
5240 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as sinon from 'sinon';6import type * as nbformat from '@jupyterlab/nbformat';7import * as assert from 'assert';8import * as vscode from 'vscode';9import { jupyterCellOutputToCellOutput, jupyterNotebookModelToNotebookData } from '../deserializers';10import { createMarkdownCellFromNotebookCell, getCellMetadata } from '../serializers';1112function deepStripProperties(obj: any, props: string[]) {13for (const prop in obj) {14if (obj[prop]) {15delete obj[prop];16} else if (typeof obj[prop] === 'object') {17deepStripProperties(obj[prop], props);18}19}20}21suite(`ipynb serializer`, () => {22let disposables: vscode.Disposable[] = [];23setup(() => {24disposables = [];25});26teardown(async () => {27disposables.forEach(d => d.dispose());28disposables = [];29sinon.restore();30});3132const base64EncodedImage =33'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOUlZL6DwAB/wFSU1jVmgAAAABJRU5ErkJggg==';34test('Deserialize', async () => {35const cells: nbformat.ICell[] = [36{37cell_type: 'code',38execution_count: 10,39outputs: [],40source: 'print(1)',41metadata: {}42},43{44cell_type: 'code',45outputs: [],46source: 'print(2)',47metadata: {}48},49{50cell_type: 'markdown',51source: '# HEAD',52metadata: {}53}54];55const notebook = jupyterNotebookModelToNotebookData({ cells }, 'python');56assert.ok(notebook);5758const expectedCodeCell = new vscode.NotebookCellData(vscode.NotebookCellKind.Code, 'print(1)', 'python');59expectedCodeCell.outputs = [];60expectedCodeCell.metadata = { execution_count: 10, metadata: {} };61expectedCodeCell.executionSummary = { executionOrder: 10 };6263const expectedCodeCell2 = new vscode.NotebookCellData(vscode.NotebookCellKind.Code, 'print(2)', 'python');64expectedCodeCell2.outputs = [];65expectedCodeCell2.metadata = { execution_count: null, metadata: {} };66expectedCodeCell2.executionSummary = {};6768const expectedMarkdownCell = new vscode.NotebookCellData(vscode.NotebookCellKind.Markup, '# HEAD', 'markdown');69expectedMarkdownCell.outputs = [];70expectedMarkdownCell.metadata = {71metadata: {}72};7374assert.deepStrictEqual(notebook.cells, [expectedCodeCell, expectedCodeCell2, expectedMarkdownCell]);75});7677test('Deserialize cells without metadata field', async () => {78// Test case for issue where cells without metadata field cause "Cannot read properties of undefined" error79const cells: nbformat.ICell[] = [80{81cell_type: 'code',82execution_count: 10,83outputs: [],84source: 'print(1)'85},86{87cell_type: 'code',88outputs: [],89source: 'print(2)'90},91{92cell_type: 'markdown',93source: '# HEAD'94}95] as unknown as nbformat.ICell[];96const notebook = jupyterNotebookModelToNotebookData({ cells }, 'python');97assert.ok(notebook);98assert.strictEqual(notebook.cells.length, 3);99100// First cell with execution count101const cell1 = notebook.cells[0];102assert.strictEqual(cell1.kind, vscode.NotebookCellKind.Code);103assert.strictEqual(cell1.value, 'print(1)');104assert.strictEqual(cell1.languageId, 'python');105assert.ok(cell1.metadata);106assert.strictEqual(cell1.metadata.execution_count, 10);107assert.deepStrictEqual(cell1.executionSummary, { executionOrder: 10 });108109// Second cell without execution count110const cell2 = notebook.cells[1];111assert.strictEqual(cell2.kind, vscode.NotebookCellKind.Code);112assert.strictEqual(cell2.value, 'print(2)');113assert.strictEqual(cell2.languageId, 'python');114assert.ok(cell2.metadata);115assert.strictEqual(cell2.metadata.execution_count, null);116assert.deepStrictEqual(cell2.executionSummary, {});117118// Markdown cell119const cell3 = notebook.cells[2];120assert.strictEqual(cell3.kind, vscode.NotebookCellKind.Markup);121assert.strictEqual(cell3.value, '# HEAD');122assert.strictEqual(cell3.languageId, 'markdown');123});124125test('Serialize', async () => {126const markdownCell = new vscode.NotebookCellData(vscode.NotebookCellKind.Markup, '# header1', 'markdown');127markdownCell.metadata = {128attachments: {129'image.png': {130'image/png': 'abc'131}132},133id: '123',134metadata: {135foo: 'bar'136}137};138139const cellMetadata = getCellMetadata({ cell: markdownCell });140assert.deepStrictEqual(cellMetadata, {141id: '123',142metadata: {143foo: 'bar',144},145attachments: {146'image.png': {147'image/png': 'abc'148}149}150});151152const markdownCell2 = new vscode.NotebookCellData(vscode.NotebookCellKind.Markup, '# header1', 'markdown');153markdownCell2.metadata = {154id: '123',155metadata: {156foo: 'bar'157},158attachments: {159'image.png': {160'image/png': 'abc'161}162}163};164165const nbMarkdownCell = createMarkdownCellFromNotebookCell(markdownCell);166const nbMarkdownCell2 = createMarkdownCellFromNotebookCell(markdownCell2);167assert.deepStrictEqual(nbMarkdownCell, nbMarkdownCell2);168169assert.deepStrictEqual(nbMarkdownCell, {170cell_type: 'markdown',171source: ['# header1'],172metadata: {173foo: 'bar',174},175attachments: {176'image.png': {177'image/png': 'abc'178}179},180id: '123'181});182});183184suite('Outputs', () => {185function validateCellOutputTranslation(186outputs: nbformat.IOutput[],187expectedOutputs: vscode.NotebookCellOutput[],188propertiesToExcludeFromComparison: string[] = []189) {190const cells: nbformat.ICell[] = [191{192cell_type: 'code',193execution_count: 10,194outputs,195source: 'print(1)',196metadata: {}197}198];199const notebook = jupyterNotebookModelToNotebookData({ cells }, 'python');200201// OutputItems contain an `id` property generated by VSC.202// Exclude that property when comparing.203const propertiesToExclude = propertiesToExcludeFromComparison.concat(['id']);204const actualOuts = notebook.cells[0].outputs;205deepStripProperties(actualOuts, propertiesToExclude);206deepStripProperties(expectedOutputs, propertiesToExclude);207assert.deepStrictEqual(actualOuts, expectedOutputs);208}209210test('Empty output', () => {211validateCellOutputTranslation([], []);212});213214test('Stream output', () => {215validateCellOutputTranslation(216[217{218output_type: 'stream',219name: 'stderr',220text: 'Error'221},222{223output_type: 'stream',224name: 'stdout',225text: 'NoError'226}227],228[229new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stderr('Error')], {230outputType: 'stream'231}),232new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stdout('NoError')], {233outputType: 'stream'234})235]236);237});238test('Stream output and line endings', () => {239validateCellOutputTranslation(240[241{242output_type: 'stream',243name: 'stdout',244text: [245'Line1\n',246'\n',247'Line3\n',248'Line4'249]250}251],252[253new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stdout('Line1\n\nLine3\nLine4')], {254outputType: 'stream'255})256]257);258validateCellOutputTranslation(259[260{261output_type: 'stream',262name: 'stdout',263text: [264'Hello\n',265'Hello\n',266'Hello\n',267'Hello\n',268'Hello\n',269'Hello\n'270]271}272],273[274new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stdout('Hello\nHello\nHello\nHello\nHello\nHello\n')], {275outputType: 'stream'276})277]278);279});280test('Multi-line Stream output', () => {281validateCellOutputTranslation(282[283{284name: 'stdout',285output_type: 'stream',286text: [287'Epoch 1/5\n',288'...\n',289'Epoch 2/5\n',290'...\n',291'Epoch 3/5\n',292'...\n',293'Epoch 4/5\n',294'...\n',295'Epoch 5/5\n',296'...\n'297]298}299],300[301new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stdout(['Epoch 1/5\n',302'...\n',303'Epoch 2/5\n',304'...\n',305'Epoch 3/5\n',306'...\n',307'Epoch 4/5\n',308'...\n',309'Epoch 5/5\n',310'...\n'].join(''))], {311outputType: 'stream'312})313]314);315});316317test('Multi-line Stream output (last empty line should not be saved in ipynb)', () => {318validateCellOutputTranslation(319[320{321name: 'stderr',322output_type: 'stream',323text: [324'Epoch 1/5\n',325'...\n',326'Epoch 2/5\n',327'...\n',328'Epoch 3/5\n',329'...\n',330'Epoch 4/5\n',331'...\n',332'Epoch 5/5\n',333'...\n'334]335}336],337[338new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stderr(['Epoch 1/5\n',339'...\n',340'Epoch 2/5\n',341'...\n',342'Epoch 3/5\n',343'...\n',344'Epoch 4/5\n',345'...\n',346'Epoch 5/5\n',347'...\n',348// This last empty line should not be saved in ipynb.349'\n'].join(''))], {350outputType: 'stream'351})352]353);354});355356test('Streamed text with Ansi characters', async () => {357validateCellOutputTranslation(358[359{360name: 'stderr',361text: '\u001b[K\u001b[33m✅ \u001b[0m Loading\n',362output_type: 'stream'363}364],365[366new vscode.NotebookCellOutput(367[vscode.NotebookCellOutputItem.stderr('\u001b[K\u001b[33m✅ \u001b[0m Loading\n')],368{369outputType: 'stream'370}371)372]373);374});375376test('Streamed text with angle bracket characters', async () => {377validateCellOutputTranslation(378[379{380name: 'stderr',381text: '1 is < 2',382output_type: 'stream'383}384],385[386new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stderr('1 is < 2')], {387outputType: 'stream'388})389]390);391});392393test('Streamed text with angle bracket characters and ansi chars', async () => {394validateCellOutputTranslation(395[396{397name: 'stderr',398text: '1 is < 2\u001b[K\u001b[33m✅ \u001b[0m Loading\n',399output_type: 'stream'400}401],402[403new vscode.NotebookCellOutput(404[vscode.NotebookCellOutputItem.stderr('1 is < 2\u001b[K\u001b[33m✅ \u001b[0m Loading\n')],405{406outputType: 'stream'407}408)409]410);411});412413test('Error', async () => {414validateCellOutputTranslation(415[416{417ename: 'Error Name',418evalue: 'Error Value',419traceback: ['stack1', 'stack2', 'stack3'],420output_type: 'error'421}422],423[424new vscode.NotebookCellOutput(425[426vscode.NotebookCellOutputItem.error({427name: 'Error Name',428message: 'Error Value',429stack: ['stack1', 'stack2', 'stack3'].join('\n')430})431],432{433outputType: 'error',434originalError: {435ename: 'Error Name',436evalue: 'Error Value',437traceback: ['stack1', 'stack2', 'stack3'],438output_type: 'error'439}440}441)442]443);444});445446['display_data', 'execute_result'].forEach(output_type => {447suite(`Rich output for output_type = ${output_type}`, () => {448// Properties to exclude when comparing.449let propertiesToExcludeFromComparison: string[] = [];450setup(() => {451if (output_type === 'display_data') {452// With display_data the execution_count property will never exist in the output.453// We can ignore that (as it will never exist).454// But we leave it in the case of `output_type === 'execute_result'`455propertiesToExcludeFromComparison = ['execution_count', 'executionCount'];456}457});458459test('Text mimeType output', async () => {460validateCellOutputTranslation(461[462{463data: {464'text/plain': 'Hello World!'465},466output_type,467metadata: {},468execution_count: 1469}470],471[472new vscode.NotebookCellOutput(473[new vscode.NotebookCellOutputItem(Buffer.from('Hello World!', 'utf8'), 'text/plain')],474{475outputType: output_type,476metadata: {}, // display_data & execute_result always have metadata.477executionCount: 1478}479)480],481propertiesToExcludeFromComparison482);483});484485test('png,jpeg images', async () => {486validateCellOutputTranslation(487[488{489execution_count: 1,490data: {491'image/png': base64EncodedImage,492'image/jpeg': base64EncodedImage493},494metadata: {},495output_type496}497],498[499new vscode.NotebookCellOutput(500[501new vscode.NotebookCellOutputItem(Buffer.from(base64EncodedImage, 'base64'), 'image/png'),502new vscode.NotebookCellOutputItem(Buffer.from(base64EncodedImage, 'base64'), 'image/jpeg')503],504{505executionCount: 1,506outputType: output_type,507metadata: {} // display_data & execute_result always have metadata.508}509)510],511propertiesToExcludeFromComparison512);513});514515test('png image with a light background', async () => {516validateCellOutputTranslation(517[518{519execution_count: 1,520data: {521'image/png': base64EncodedImage522},523metadata: {524needs_background: 'light'525},526output_type527}528],529[530new vscode.NotebookCellOutput(531[new vscode.NotebookCellOutputItem(Buffer.from(base64EncodedImage, 'base64'), 'image/png')],532{533executionCount: 1,534metadata: {535needs_background: 'light'536},537outputType: output_type538}539)540],541propertiesToExcludeFromComparison542);543});544545test('png image with a dark background', async () => {546validateCellOutputTranslation(547[548{549execution_count: 1,550data: {551'image/png': base64EncodedImage552},553metadata: {554needs_background: 'dark'555},556output_type557}558],559[560new vscode.NotebookCellOutput(561[new vscode.NotebookCellOutputItem(Buffer.from(base64EncodedImage, 'base64'), 'image/png')],562{563executionCount: 1,564metadata: {565needs_background: 'dark'566},567outputType: output_type568}569)570],571propertiesToExcludeFromComparison572);573});574575test('png image with custom dimensions', async () => {576validateCellOutputTranslation(577[578{579execution_count: 1,580data: {581'image/png': base64EncodedImage582},583metadata: {584'image/png': { height: '111px', width: '999px' }585},586output_type587}588],589[590new vscode.NotebookCellOutput(591[new vscode.NotebookCellOutputItem(Buffer.from(base64EncodedImage, 'base64'), 'image/png')],592{593executionCount: 1,594metadata: {595'image/png': { height: '111px', width: '999px' }596},597outputType: output_type598}599)600],601propertiesToExcludeFromComparison602);603});604605test('png allowed to scroll', async () => {606validateCellOutputTranslation(607[608{609execution_count: 1,610data: {611'image/png': base64EncodedImage612},613metadata: {614unconfined: true,615'image/png': { width: '999px' }616},617output_type618}619],620[621new vscode.NotebookCellOutput(622[new vscode.NotebookCellOutputItem(Buffer.from(base64EncodedImage, 'base64'), 'image/png')],623{624executionCount: 1,625metadata: {626unconfined: true,627'image/png': { width: '999px' }628},629outputType: output_type630}631)632],633propertiesToExcludeFromComparison634);635});636});637});638});639640suite('Output Order', () => {641test('Verify order of outputs', async () => {642const dataAndExpectedOrder: { output: nbformat.IDisplayData; expectedMimeTypesOrder: string[] }[] = [643{644output: {645data: {646'application/vnd.vegalite.v4+json': 'some json',647'text/html': '<a>Hello</a>'648},649metadata: {},650output_type: 'display_data'651},652expectedMimeTypesOrder: ['application/vnd.vegalite.v4+json', 'text/html']653},654{655output: {656data: {657'application/vnd.vegalite.v4+json': 'some json',658'application/javascript': 'some js',659'text/plain': 'some text',660'text/html': '<a>Hello</a>'661},662metadata: {},663output_type: 'display_data'664},665expectedMimeTypesOrder: [666'application/vnd.vegalite.v4+json',667'text/html',668'application/javascript',669'text/plain'670]671},672{673output: {674data: {675'application/vnd.vegalite.v4+json': '', // Empty, should give preference to other mimetypes.676'application/javascript': 'some js',677'text/plain': 'some text',678'text/html': '<a>Hello</a>'679},680metadata: {},681output_type: 'display_data'682},683expectedMimeTypesOrder: [684'text/html',685'application/javascript',686'text/plain',687'application/vnd.vegalite.v4+json'688]689},690{691output: {692data: {693'text/plain': 'some text',694'text/html': '<a>Hello</a>'695},696metadata: {},697output_type: 'display_data'698},699expectedMimeTypesOrder: ['text/html', 'text/plain']700},701{702output: {703data: {704'application/javascript': 'some js',705'text/plain': 'some text'706},707metadata: {},708output_type: 'display_data'709},710expectedMimeTypesOrder: ['application/javascript', 'text/plain']711},712{713output: {714data: {715'image/svg+xml': 'some svg',716'text/plain': 'some text'717},718metadata: {},719output_type: 'display_data'720},721expectedMimeTypesOrder: ['image/svg+xml', 'text/plain']722},723{724output: {725data: {726'text/latex': 'some latex',727'text/plain': 'some text'728},729metadata: {},730output_type: 'display_data'731},732expectedMimeTypesOrder: ['text/latex', 'text/plain']733},734{735output: {736data: {737'application/vnd.jupyter.widget-view+json': 'some widget',738'text/plain': 'some text'739},740metadata: {},741output_type: 'display_data'742},743expectedMimeTypesOrder: ['application/vnd.jupyter.widget-view+json', 'text/plain']744},745{746output: {747data: {748'text/plain': 'some text',749'image/svg+xml': 'some svg',750'image/png': 'some png'751},752metadata: {},753output_type: 'display_data'754},755expectedMimeTypesOrder: ['image/png', 'image/svg+xml', 'text/plain']756}757];758759dataAndExpectedOrder.forEach(({ output, expectedMimeTypesOrder }) => {760const sortedOutputs = jupyterCellOutputToCellOutput(output);761const mimeTypes = sortedOutputs.items.map((item) => item.mime).join(',');762assert.equal(mimeTypes, expectedMimeTypesOrder.join(','));763});764});765});766});767768769