From ea0346f4bdcd95fa72bbc8786d4b6bda024ad4b1 Mon Sep 17 00:00:00 2001 From: Marko Paleka Date: Mon, 8 Jun 2026 17:12:31 +0200 Subject: [PATCH 1/3] fix: default input-select select_type to 'from-options' New Input Select blocks were created with deepnote_variable_select_type set to null. The @deepnote/blocks serializer validates this field against a strict 'from-options' | 'from-variable' enum and rejects null, which made freshly-added Input Select blocks impossible to save. Default the field to 'from-options' so new blocks serialize cleanly, and add regression tests covering the default value and idempotent round-tripping of the cell value (guarding against repeated JSON escaping). --- .../converters/inputConverters.unit.test.ts | 42 +++++++++++++++++++ src/notebooks/deepnote/deepnoteSchemas.ts | 6 ++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/notebooks/deepnote/converters/inputConverters.unit.test.ts b/src/notebooks/deepnote/converters/inputConverters.unit.test.ts index 07e930f73b..551d179c79 100644 --- a/src/notebooks/deepnote/converters/inputConverters.unit.test.ts +++ b/src/notebooks/deepnote/converters/inputConverters.unit.test.ts @@ -13,6 +13,7 @@ import { ButtonBlockConverter } from './inputConverters'; import { DEEPNOTE_VSCODE_RAW_CONTENT_KEY } from './constants'; +import { DeepnoteSelectInputMetadataSchema } from '../deepnoteSchemas'; suite('InputTextBlockConverter', () => { let converter: InputTextBlockConverter; @@ -269,6 +270,47 @@ suite('InputTextareaBlockConverter', () => { }); }); +suite('Input Select block — save/serialize regression', () => { + test('new input-select metadata defaults select_type to a valid enum value, not null', () => { + // The @deepnote/blocks serializer validates deepnote_variable_select_type + // against a strict 'from-options' | 'from-variable' enum and rejects null. + // A null default made freshly-created Input Select blocks impossible to + // save, which also kicked off a runaway content-reformat loop. + const metadata = DeepnoteSelectInputMetadataSchema.parse({ deepnote_variable_name: 'input_1' }); + + assert.strictEqual(metadata.deepnote_variable_select_type, 'from-options'); + }); + + test('round-trips block -> cell -> block -> cell without growing the value (no runaway escaping)', () => { + const converter = new InputSelectBlockConverter(); + const block: DeepnoteBlock = { + blockGroup: 'g', + content: '', + id: 'b1', + metadata: { + deepnote_variable_name: 'input_1', + deepnote_variable_value: 'Option 1', + deepnote_variable_options: ['Option 1', 'Option 2'], + deepnote_variable_select_type: 'from-options', + deepnote_variable_custom_options: ['Option 1', 'Option 2'], + deepnote_variable_selected_variable: '' + }, + sortingKey: 'x', + type: 'input-select' + }; + + const firstCell = converter.convertToCell(block); + converter.applyChangesToBlock(block, firstCell); + const secondCell = converter.convertToCell(block); + + // Each pass must be idempotent: JSON.stringify must not re-escape an + // already-escaped value. Previously the value grew without bound and + // froze the renderer with megabytes of backslashes. + assert.strictEqual(firstCell.value, '"Option 1"'); + assert.strictEqual(secondCell.value, firstCell.value); + }); +}); + suite('InputSelectBlockConverter', () => { let converter: InputSelectBlockConverter; diff --git a/src/notebooks/deepnote/deepnoteSchemas.ts b/src/notebooks/deepnote/deepnoteSchemas.ts index 7c8712b467..39bf5ad433 100644 --- a/src/notebooks/deepnote/deepnoteSchemas.ts +++ b/src/notebooks/deepnote/deepnoteSchemas.ts @@ -117,9 +117,11 @@ export const DeepnoteSelectInputMetadataSchema = DeepnoteBaseInputWithLabelMetad .transform((val) => val ?? DEEPNOTE_SELECT_INPUT_DEFAULT_OPTIONS), deepnote_variable_select_type: z .enum(['from-options', 'from-variable']) - // .string() + // Default to 'from-options' (not null): the @deepnote/blocks serialize + // schema rejects null here, which previously made new Input Select blocks + // impossible to save and could trigger a content-reformat loop. .nullish() - .transform((val) => val ?? null), + .transform((val) => val ?? 'from-options'), deepnote_allow_multiple_values: z .boolean() .nullish() From 64d9656f7511f88ba5a6aef4595eefa108aa166b Mon Sep 17 00:00:00 2001 From: tomas Date: Wed, 19 Aug 2026 08:22:31 +0000 Subject: [PATCH 2/3] fix: stop emitting null defaults that @deepnote/blocks rejects on save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deepnote_allowed_file_extensions had the same defect as deepnote_variable_select_type: the local schema defaulted it to null, which the @deepnote/blocks block schema rejects, so freshly-added Input File blocks could not be saved either. Default it to undefined instead, matching the package's optional-string field. Both consumers already read it as `string | undefined` behind a falsy guard. Cover the class rather than the two instances: getInputBlockMetadata now has a test per entry in INPUT_BLOCK_TYPES asserting the default metadata survives serializeDeepnoteFile, so any future drift between the local schemas and the package fails at the boundary instead of at a user's save. Drop the round-trip test added alongside the select_type fix — it hardcodes select_type, so it passes with or without that fix, and the escaping behavior it describes is already covered by the InputSelectBlockConverter suite. The remaining select_type assertion moves into that suite. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017kn67m6zUgugyCve39giNT --- .../converters/inputConverters.unit.test.ts | 46 ++----------------- ...epnoteNotebookCommandListener.unit.test.ts | 35 +++++++++++--- src/notebooks/deepnote/deepnoteSchemas.ts | 9 ++-- 3 files changed, 37 insertions(+), 53 deletions(-) diff --git a/src/notebooks/deepnote/converters/inputConverters.unit.test.ts b/src/notebooks/deepnote/converters/inputConverters.unit.test.ts index 551d179c79..080c1dec7d 100644 --- a/src/notebooks/deepnote/converters/inputConverters.unit.test.ts +++ b/src/notebooks/deepnote/converters/inputConverters.unit.test.ts @@ -13,7 +13,6 @@ import { ButtonBlockConverter } from './inputConverters'; import { DEEPNOTE_VSCODE_RAW_CONTENT_KEY } from './constants'; -import { DeepnoteSelectInputMetadataSchema } from '../deepnoteSchemas'; suite('InputTextBlockConverter', () => { let converter: InputTextBlockConverter; @@ -270,47 +269,6 @@ suite('InputTextareaBlockConverter', () => { }); }); -suite('Input Select block — save/serialize regression', () => { - test('new input-select metadata defaults select_type to a valid enum value, not null', () => { - // The @deepnote/blocks serializer validates deepnote_variable_select_type - // against a strict 'from-options' | 'from-variable' enum and rejects null. - // A null default made freshly-created Input Select blocks impossible to - // save, which also kicked off a runaway content-reformat loop. - const metadata = DeepnoteSelectInputMetadataSchema.parse({ deepnote_variable_name: 'input_1' }); - - assert.strictEqual(metadata.deepnote_variable_select_type, 'from-options'); - }); - - test('round-trips block -> cell -> block -> cell without growing the value (no runaway escaping)', () => { - const converter = new InputSelectBlockConverter(); - const block: DeepnoteBlock = { - blockGroup: 'g', - content: '', - id: 'b1', - metadata: { - deepnote_variable_name: 'input_1', - deepnote_variable_value: 'Option 1', - deepnote_variable_options: ['Option 1', 'Option 2'], - deepnote_variable_select_type: 'from-options', - deepnote_variable_custom_options: ['Option 1', 'Option 2'], - deepnote_variable_selected_variable: '' - }, - sortingKey: 'x', - type: 'input-select' - }; - - const firstCell = converter.convertToCell(block); - converter.applyChangesToBlock(block, firstCell); - const secondCell = converter.convertToCell(block); - - // Each pass must be idempotent: JSON.stringify must not re-escape an - // already-escaped value. Previously the value grew without bound and - // froze the renderer with megabytes of backslashes. - assert.strictEqual(firstCell.value, '"Option 1"'); - assert.strictEqual(secondCell.value, firstCell.value); - }); -}); - suite('InputSelectBlockConverter', () => { let converter: InputSelectBlockConverter; @@ -318,6 +276,10 @@ suite('InputSelectBlockConverter', () => { converter = new InputSelectBlockConverter(); }); + test('defaults select_type to from-options', () => { + assert.strictEqual(converter.defaultConfig().deepnote_variable_select_type, 'from-options'); + }); + suite('convertToCell', () => { test('converts input-select block to Python cell with quoted value', () => { const block: DeepnoteBlock = { diff --git a/src/notebooks/deepnote/deepnoteNotebookCommandListener.unit.test.ts b/src/notebooks/deepnote/deepnoteNotebookCommandListener.unit.test.ts index dd54a44e5e..75192e291a 100644 --- a/src/notebooks/deepnote/deepnoteNotebookCommandListener.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteNotebookCommandListener.unit.test.ts @@ -1,3 +1,4 @@ +import { serializeDeepnoteFile, type DeepnoteBlock } from '@deepnote/blocks'; import { assert } from 'chai'; import * as sinon from 'sinon'; import { when, reset, anything, mock, instance } from 'ts-mockito'; @@ -14,7 +15,9 @@ import { import { DeepnoteNotebookCommandListener, + getInputBlockMetadata, getNextDeepnoteVariableName, + INPUT_BLOCK_TYPES, InputBlockType } from './deepnoteNotebookCommandListener'; import { formatInputBlockCellContent, getInputBlockLanguage } from './inputBlockContentFormatter'; @@ -25,7 +28,13 @@ import { createMockedNotebookDocument } from '../../test/datascience/editor-inte import { WrappedError } from '../../platform/errors/types'; import { DATAFRAME_SQL_INTEGRATION_ID } from '../../platform/notebooks/deepnote/integrationTypes'; import { mockedVSCodeNamespaces } from '../../test/vscode-mock'; -import { createMockCell } from './deepnoteTestHelpers'; +import { + createDeepnoteBlock, + createDeepnoteFile, + createDeepnoteNotebook, + createDeepnoteProject, + createMockCell +} from './deepnoteTestHelpers'; suite('DeepnoteNotebookCommandListener', () => { let commandListener: DeepnoteNotebookCommandListener; @@ -347,6 +356,23 @@ suite('DeepnoteNotebookCommandListener', () => { }); }); + suite('getInputBlockMetadata', () => { + INPUT_BLOCK_TYPES.forEach((blockType) => { + test(`default ${blockType} metadata is accepted by the @deepnote/blocks serializer`, () => { + const block = { + ...createDeepnoteBlock(), + metadata: getInputBlockMetadata(blockType, 'input_1'), + type: blockType + } as DeepnoteBlock; + const file = createDeepnoteFile({ + project: createDeepnoteProject({ notebooks: [createDeepnoteNotebook({ blocks: [block] })] }) + }); + + assert.doesNotThrow(() => serializeDeepnoteFile(file)); + }); + }); + }); + suite('addBlock', () => { let sandbox: sinon.SinonSandbox; @@ -538,12 +564,7 @@ suite('DeepnoteNotebookCommandListener', () => { selection: undefined, expectedInsertIndex: 0, expectedVariableName: 'input_1', - expectedMetadataKeys: [ - 'deepnote_variable_name', - 'deepnote_input_label', - 'deepnote_variable_value', - 'deepnote_allowed_file_extensions' - ] + expectedMetadataKeys: ['deepnote_variable_name', 'deepnote_input_label', 'deepnote_variable_value'] }, { description: 'should add button block with correct metadata', diff --git a/src/notebooks/deepnote/deepnoteSchemas.ts b/src/notebooks/deepnote/deepnoteSchemas.ts index 39bf5ad433..cb05e85020 100644 --- a/src/notebooks/deepnote/deepnoteSchemas.ts +++ b/src/notebooks/deepnote/deepnoteSchemas.ts @@ -1,5 +1,9 @@ import { z } from 'zod'; +// Block metadata parsed here is handed back to @deepnote/blocks on save, and its schema +// rejects an explicit null on optional fields — deepnote_variable_default_value is the one +// field it coerces. Defaults below must resolve to a valid value or to undefined. + export const DeepnoteChartBigNumberOutputSchema = z.object({ title: z.string().nullish(), value: z.string().nullish(), @@ -117,9 +121,6 @@ export const DeepnoteSelectInputMetadataSchema = DeepnoteBaseInputWithLabelMetad .transform((val) => val ?? DEEPNOTE_SELECT_INPUT_DEFAULT_OPTIONS), deepnote_variable_select_type: z .enum(['from-options', 'from-variable']) - // Default to 'from-options' (not null): the @deepnote/blocks serialize - // schema rejects null here, which previously made new Input Select blocks - // impossible to save and could trigger a content-reformat loop. .nullish() .transform((val) => val ?? 'from-options'), deepnote_allow_multiple_values: z @@ -210,7 +211,7 @@ export const DeepnoteFileInputMetadataSchema = DeepnoteBaseInputWithLabelMetadat deepnote_allowed_file_extensions: z .string() .nullish() - .transform((val) => val ?? null) + .transform((val) => val ?? undefined) }); export const DeepnoteButtonMetadataSchema = DeepnoteBaseInputMetadataSchema.extend({ From 8df4516009ebaa645823044db2a8f72d5f2b2e02 Mon Sep 17 00:00:00 2001 From: tomas Date: Wed, 19 Aug 2026 10:22:52 +0000 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20address=20review=20=E2=80=94=20?= =?UTF-8?q?drop=20redundant=20test,=20comment,=20and=20type=20cast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The select_type default assertion duplicated the getInputBlockMetadata boundary test, which already fails on that exact default; removing it returns inputConverters.unit.test.ts to its original state. Build the block under test with createBlockFromPocket instead of hand-assembling one. The cast was papering over a real gap — getInputBlockMetadata's return type is not correlated with its blockType argument, so TypeScript cannot pick a branch of the DeepnoteBlock union. Going through the production cell-to-block path types cleanly and exercises what actually runs on save. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017kn67m6zUgugyCve39giNT --- .../converters/inputConverters.unit.test.ts | 4 ---- .../deepnoteNotebookCommandListener.unit.test.ts | 14 +++++++------- src/notebooks/deepnote/deepnoteSchemas.ts | 4 ---- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/src/notebooks/deepnote/converters/inputConverters.unit.test.ts b/src/notebooks/deepnote/converters/inputConverters.unit.test.ts index 080c1dec7d..07e930f73b 100644 --- a/src/notebooks/deepnote/converters/inputConverters.unit.test.ts +++ b/src/notebooks/deepnote/converters/inputConverters.unit.test.ts @@ -276,10 +276,6 @@ suite('InputSelectBlockConverter', () => { converter = new InputSelectBlockConverter(); }); - test('defaults select_type to from-options', () => { - assert.strictEqual(converter.defaultConfig().deepnote_variable_select_type, 'from-options'); - }); - suite('convertToCell', () => { test('converts input-select block to Python cell with quoted value', () => { const block: DeepnoteBlock = { diff --git a/src/notebooks/deepnote/deepnoteNotebookCommandListener.unit.test.ts b/src/notebooks/deepnote/deepnoteNotebookCommandListener.unit.test.ts index 75192e291a..09c118fe82 100644 --- a/src/notebooks/deepnote/deepnoteNotebookCommandListener.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteNotebookCommandListener.unit.test.ts @@ -1,4 +1,4 @@ -import { serializeDeepnoteFile, type DeepnoteBlock } from '@deepnote/blocks'; +import { serializeDeepnoteFile } from '@deepnote/blocks'; import { assert } from 'chai'; import * as sinon from 'sinon'; import { when, reset, anything, mock, instance } from 'ts-mockito'; @@ -26,10 +26,10 @@ import { IConfigurationService, IDisposable } from '../../platform/common/types' import * as notebookUpdater from '../../kernels/execution/notebookUpdater'; import { createMockedNotebookDocument } from '../../test/datascience/editor-integration/helpers'; import { WrappedError } from '../../platform/errors/types'; +import { createBlockFromPocket } from '../../platform/deepnote/pocket'; import { DATAFRAME_SQL_INTEGRATION_ID } from '../../platform/notebooks/deepnote/integrationTypes'; import { mockedVSCodeNamespaces } from '../../test/vscode-mock'; import { - createDeepnoteBlock, createDeepnoteFile, createDeepnoteNotebook, createDeepnoteProject, @@ -359,11 +359,11 @@ suite('DeepnoteNotebookCommandListener', () => { suite('getInputBlockMetadata', () => { INPUT_BLOCK_TYPES.forEach((blockType) => { test(`default ${blockType} metadata is accepted by the @deepnote/blocks serializer`, () => { - const block = { - ...createDeepnoteBlock(), - metadata: getInputBlockMetadata(blockType, 'input_1'), - type: blockType - } as DeepnoteBlock; + const metadata = getInputBlockMetadata(blockType, 'input_1'); + const cell = new NotebookCellData(NotebookCellKind.Code, '', 'python'); + cell.metadata = { __deepnotePocket: { type: blockType, ...metadata }, ...metadata }; + + const block = createBlockFromPocket(cell, 0); const file = createDeepnoteFile({ project: createDeepnoteProject({ notebooks: [createDeepnoteNotebook({ blocks: [block] })] }) }); diff --git a/src/notebooks/deepnote/deepnoteSchemas.ts b/src/notebooks/deepnote/deepnoteSchemas.ts index cb05e85020..bfb7aa502f 100644 --- a/src/notebooks/deepnote/deepnoteSchemas.ts +++ b/src/notebooks/deepnote/deepnoteSchemas.ts @@ -1,9 +1,5 @@ import { z } from 'zod'; -// Block metadata parsed here is handed back to @deepnote/blocks on save, and its schema -// rejects an explicit null on optional fields — deepnote_variable_default_value is the one -// field it coerces. Defaults below must resolve to a valid value or to undefined. - export const DeepnoteChartBigNumberOutputSchema = z.object({ title: z.string().nullish(), value: z.string().nullish(),