Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion src/main/notebook/ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ describe('notebook IPC handlers', () => {
}),
runCell: vi.fn().mockResolvedValue({ runId: 'run-2', status: 'completed' }),
exportIpynb: vi.fn().mockResolvedValue({ saved: true, filePath: '/tmp/session.ipynb' }),
importIpynb: vi.fn().mockResolvedValue({ imported: true, cellCount: 1 }),
openInJupyterLab: vi
.fn()
.mockResolvedValue({ opened: true, url: 'http://localhost:8888', alreadyRunning: false }),
beginCodeCell: vi.fn().mockResolvedValue({ cellId: 'cell-1', writeId: 'write-1' }),
appendCodeCell: vi.fn().mockResolvedValue({ receivedBytes: 5 }),
finishCodeCell: vi.fn().mockResolvedValue({ status: 'idle' }),
Expand Down Expand Up @@ -69,7 +73,13 @@ describe('notebook IPC handlers', () => {
})
await handlers.restart({ sessionId: 'session-1', workspaceCwd: '/workspace' })
await handlers.shutdown({ sessionId: 'session-1', workspaceCwd: '/workspace' })
await handlers.exportIpynb({ sessionId: 'session-1', workspaceCwd: '/workspace', kernel: 'python' })
await handlers.exportIpynb({
sessionId: 'session-1',
workspaceCwd: '/workspace',
kernel: 'python'
})
await handlers.importIpynb({ sessionId: 'session-1', workspaceCwd: '/workspace' })
await handlers.openInJupyterLab({ sessionId: 'session-1', workspaceCwd: '/workspace' })

expect(service.execute).toHaveBeenCalledWith({
sessionId: 'session-1',
Expand All @@ -96,6 +106,14 @@ describe('notebook IPC handlers', () => {
workspaceCwd: '/workspace',
kernel: 'python'
})
expect(service.importIpynb).toHaveBeenCalledWith({
sessionId: 'session-1',
workspaceCwd: '/workspace'
})
expect(service.openInJupyterLab).toHaveBeenCalledWith({
sessionId: 'session-1',
workspaceCwd: '/workspace'
})
})

it('registers every notebook channel and forwards the renderer payload unchanged', async () => {
Expand All @@ -108,6 +126,12 @@ describe('notebook IPC handlers', () => {
runCell: vi.fn().mockResolvedValue({ runId: 'run-1', status: 'completed' }),
execute: vi.fn().mockResolvedValue({ runId: 'run-2', status: 'completed' }),
exportIpynb: vi.fn().mockResolvedValue({ saved: false }),
importIpynb: vi.fn().mockResolvedValue({ imported: false }),
openInJupyterLab: vi.fn().mockResolvedValue({
opened: true,
url: 'http://localhost:8888',
alreadyRunning: false
}),
restart: vi.fn().mockResolvedValue({ sessionId: 'session-1' }),
shutdown: vi.fn().mockResolvedValue({ sessionId: 'session-1', status: 'shutdown' })
} as unknown as NotebookRuntimeService
Expand All @@ -123,6 +147,8 @@ describe('notebook IPC handlers', () => {
'notebook:execute',
'notebook:export-ipynb',
'notebook:export-ipynb-all',
'notebook:import-ipynb',
'notebook:open-jupyterlab',
'notebook:restart',
'notebook:shutdown'
])
Expand All @@ -142,6 +168,8 @@ describe('notebook IPC handlers', () => {
await ipcHandlers.get('notebook:run-cell')?.(undefined, run)
await ipcHandlers.get('notebook:execute')?.(undefined, execute)
await ipcHandlers.get('notebook:export-ipynb')?.(undefined, session)
await ipcHandlers.get('notebook:import-ipynb')?.(undefined, session)
await ipcHandlers.get('notebook:open-jupyterlab')?.(undefined, session)
await ipcHandlers.get('notebook:restart')?.(undefined, session)
await ipcHandlers.get('notebook:shutdown')?.(undefined, session)

Expand All @@ -153,6 +181,8 @@ describe('notebook IPC handlers', () => {
expect(service.runCell).toHaveBeenCalledWith(run)
expect(service.execute).toHaveBeenCalledWith(execute)
expect(service.exportIpynb).toHaveBeenCalledWith(session)
expect(service.importIpynb).toHaveBeenCalledWith(session)
expect(service.openInJupyterLab).toHaveBeenCalledWith(session)
expect(service.restart).toHaveBeenCalledWith(session)
expect(service.shutdown).toHaveBeenCalledWith(session)
})
Expand Down
16 changes: 13 additions & 3 deletions src/main/notebook/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import type {
ExportNotebookKernelRequest,
ExportNotebookResult,
FinishNotebookCodeCellRequest,
ImportNotebookResult,
NotebookRunSummary,
OpenJupyterLabResult,
NotebookSessionReference,
NotebookSessionRequest,
NotebookSessionState,
Expand All @@ -29,12 +31,12 @@ type NotebookHandlers = {
finishCodeCell: (
request: FinishNotebookCodeCellRequest
) => ReturnType<NotebookRuntimeService['finishCodeCell']>
runCell: (
request: RunNotebookCellRequest
) => ReturnType<NotebookRuntimeService['runCell']>
runCell: (request: RunNotebookCellRequest) => ReturnType<NotebookRuntimeService['runCell']>
execute: (request: ExecuteNotebookCodeRequest) => Promise<NotebookRunSummary>
exportIpynb: (request: ExportNotebookKernelRequest) => Promise<ExportNotebookResult>
exportIpynbAll: (request: ExportNotebookAllRequest) => Promise<ExportNotebookAllResult>
importIpynb: (request: NotebookSessionRequest) => Promise<ImportNotebookResult>
openInJupyterLab: (request: NotebookSessionRequest) => Promise<OpenJupyterLabResult>
restart: (request: NotebookSessionRequest) => Promise<NotebookSessionState>
shutdown: (request: NotebookSessionRequest) => ReturnType<NotebookRuntimeService['shutdown']>
}
Expand All @@ -50,6 +52,8 @@ const createNotebookHandlers = (service: NotebookRuntimeService): NotebookHandle
execute: (request) => service.execute(request),
exportIpynb: (request) => service.exportIpynb(request),
exportIpynbAll: (request) => service.exportIpynbAll(request),
importIpynb: (request) => service.importIpynb(request),
openInJupyterLab: (request) => service.openInJupyterLab(request),
restart: (request) => service.restart(request),
shutdown: (request) => service.shutdown(request)
})
Expand Down Expand Up @@ -85,6 +89,12 @@ const registerNotebookIpcHandlers = (service: NotebookRuntimeService): void => {
ipcMain.handle('notebook:export-ipynb-all', (_event, request: ExportNotebookAllRequest) =>
handlers.exportIpynbAll(request)
)
ipcMain.handle('notebook:import-ipynb', (_event, request: NotebookSessionRequest) =>
handlers.importIpynb(request)
)
ipcMain.handle('notebook:open-jupyterlab', (_event, request: NotebookSessionRequest) =>
handlers.openInJupyterLab(request)
)
ipcMain.handle('notebook:restart', (_event, request: NotebookSessionRequest) =>
handlers.restart(request)
)
Expand Down
207 changes: 207 additions & 0 deletions src/main/notebook/ipynb-import.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { describe, expect, it } from 'vitest'

import type { NotebookRunDocument } from '../../shared/notebook'
import { runDocumentToIpynb } from './ipynb-export'
import { ipynbToRunRecords, type IpynbImportResult } from './ipynb-import'

const context = {
importedAt: 1_000,
createId: (() => {
let value = 0
return () => String(++value)
})()
}

const importNotebook = (notebook: unknown): IpynbImportResult =>
ipynbToRunRecords(notebook, {
importedAt: context.importedAt,
createId: context.createId
})

describe('ipynbToRunRecords', () => {
it('validates the nbformat major version and cells array', () => {
expect(() => importNotebook({ nbformat: 3, cells: [] })).toThrow('expected nbformat 4')
expect(() => importNotebook({ nbformat: 4 })).toThrow('cells must be an array')
})

it('imports code cells, skips markdown, and reconstructs all supported outputs', () => {
const result = importNotebook({
nbformat: 4,
nbformat_minor: 5,
metadata: { kernelspec: { name: 'python3', language: 'python' } },
cells: [
{ cell_type: 'markdown', source: ['# Title\n'] },
{
cell_type: 'code',
source: ['print("hello")\n', '2 + 2'],
execution_count: 4,
metadata: {
open_science: { kernel: 'python', environment: 'analysis' }
},
outputs: [
{ output_type: 'stream', name: 'stdout', text: ['hello\n'] },
{ output_type: 'stream', name: 'stderr', text: 'warning\n' },
{
output_type: 'error',
ename: 'ValueError',
evalue: 'bad value',
traceback: ['line 1\n', 'line 2']
},
{
output_type: 'display_data',
data: { 'image/png': 'aW1hZ2U=', 'text/plain': '<plot>' },
metadata: {}
},
{
output_type: 'display_data',
data: { 'application/json': { answer: 42 } },
metadata: {}
},
{
output_type: 'execute_result',
data: { 'text/plain': ['4'] },
metadata: {},
execution_count: 4
}
]
}
]
})

expect(result.skippedCellCount).toBe(1)
expect(result.runs).toHaveLength(1)
expect(result.runs[0]).toMatchObject({
source: 'user',
inputKind: 'cell',
kernelKind: 'python',
script: 'print("hello")\n2 + 2',
status: 'imported',
startedAt: 1_000,
executionCount: 4,
environment: 'analysis',
text: {
stdout: 'hello\n',
stderr: 'warning\n',
traceback: 'line 1\nline 2'
}
})
expect(result.runs[0].outputs).toEqual([
{ type: 'stream', name: 'stdout', text: 'hello\n' },
{ type: 'stream', name: 'stderr', text: 'warning\n' },
{
type: 'error',
name: 'ValueError',
message: 'bad value',
traceback: 'line 1\nline 2'
},
{ type: 'display', data: { 'image/png': 'aW1hZ2U=', 'text/plain': '<plot>' } },
{ type: 'json', data: { answer: 42 } },
{ type: 'text', text: '4' }
])
})

it('uses kernelspec fallback and restores downgraded bash/repl source markers', () => {
const result = importNotebook({
nbformat: 4,
metadata: { kernelspec: { name: 'ir', language: 'R' } },
cells: [
{
cell_type: 'code',
source: 'print(1)',
execution_count: null,
metadata: {},
outputs: []
},
{
cell_type: 'code',
source: ['%%bash\n', 'pwd'],
execution_count: null,
metadata: { tags: ['open-science-bash'] },
outputs: []
},
{
cell_type: 'code',
source: '%%javascript\nawait host.mcp()',
execution_count: null,
metadata: { open_science: { kernel: 'repl' } },
outputs: []
}
]
})

expect(result.runs.map(({ kernelKind, script }) => ({ kernelKind, script }))).toEqual([
{ kernelKind: 'r', script: 'print(1)' },
{ kernelKind: 'bash', script: 'pwd' },
{ kernelKind: 'repl', script: 'await host.mcp()' }
])
})

it('round-trips the supported Open Science subset in both directions', async () => {
const source = {
nbformat: 4,
nbformat_minor: 5,
metadata: { kernelspec: { display_name: 'Python 3', name: 'python3', language: 'python' } },
cells: [
{
cell_type: 'code',
id: 'source-cell',
source: ['x = 1\n', 'x'],
execution_count: 7,
metadata: { open_science: { kernel: 'python', environment: 'analysis' } },
outputs: [
{
output_type: 'execute_result',
data: { 'text/plain': '1' },
metadata: {},
execution_count: 7
}
]
}
]
}
const imported = importNotebook(source)
const document: NotebookRunDocument = {
version: 1,
projectName: 'default-project',
sessionId: 'session-1',
workspaceCwd: '/workspace',
notebookSessionRoot: '/storage/notebooks/default-project/session-1',
dataRoot: '/storage/notebooks/default-project/session-1/data',
kernel: {
language: 'python',
kernelName: 'python3',
runtimeRoot: '/storage/runtime',
lastKnownStatus: 'idle'
},
runs: imported.runs,
updatedAt: 1_000
}

const exported = await runDocumentToIpynb(document)
expect(exported.cells[0]).toMatchObject({
source: source.cells[0].source,
execution_count: 7,
outputs: source.cells[0].outputs,
metadata: {
open_science: { kernel: 'python', environment: 'analysis', status: 'imported' }
}
})

const reimported = importNotebook(exported)
expect(
reimported.runs.map(({ script, kernelKind, executionCount, outputs }) => ({
script,
kernelKind,
executionCount,
outputs
}))
).toEqual(
imported.runs.map(({ script, kernelKind, executionCount, outputs }) => ({
script,
kernelKind,
executionCount,
outputs
}))
)
})
})
Loading
Loading