Repository: stateful/vscode-runme
Pull request #613 (2023-06-14 18:19:42)

Support custom cell executions
Allows running cells with custom languages. Provides new `program` cell annotation, with which the cell will run.

Requires runme with stateful/runme#302

Depends on #590

![2023-06-13_16-53-02](https://github.com/stateful/vscode-runme/assets/16108792/ea8b9e24-cb92-4947-9151-dc33adc63c42)


mxsdev: > Let's talk this thru today. I'd like us to provide more clarity that this, in fact, works like a #! shebang. We can decide to do that in a follow-up PR (likely more UX than logic).

Agreed. I used "`program`" as a placeholder (it fits with the internal naming structure), but I presumed we would be working it out more.

> Outside of that something like /usr/bin/env python3 does not work and errors will be "swallowed". Can we improve that please. Using env as a way to allow multi argument shebangs is commonplace.

This is a bit complicated since we need to distinguish the program path from the arguments (this is probably a limitation of the syscall responsible for spawning a new process). Some paths can contain spaces, like "C:\Program Files\...". For now I can use a simple regexp on whitespace.

But eventually we would need to (in descending order of difficulty):

1. Do some basic shell parsing (maybe a use case for shlex)
2. Wrap program execution in a bash shell (makes determining if the program exists [to create error window indicating unsupported language] difficult)
3. Regexp/split on whitespace (breaks filepaths with spaces)
4. Provide a separate input field for arguments (if i recall correctly, arguments are parsed by the system/shell program, so i don't think we need to worry here)

I think I can get this working with (1), maybe in a follow-up PR.
mxsdev: @sourishkrout Just pushed changes that supports arguments in the `program`. One pretty massive issue, though: annotations don't allow spaces :/ not sure what to do about that.

I also fixed error handling. You should now get the following error popup if you don't provide a `program`, and the language is not supported:

```
Unable to automatically execute cell. To execute this cell, set the "program" field in the configuration foldout!
```

And the following error popup if the user-provided `program` does not exist:

```
Unable to locate program "<program>"
```

Patch:
diff --git a/examples/README.md b/examples/README.md
index 0379801e..a4af62d0 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -128,13 +128,30 @@ You can copy also results from the inline executed shell:
 openssl rand -base64 32
 ```
 
-## Non-Supported Languages
+## Non-Shell Languages
 
-These are shown as simple markdowns, e.g:
+These are sometimes executable by default, like for python:
 
-```py { readonly=true }
-def hello():
-    print("Hello World")
+```py
+print("Hello World")
+```
+
+Otherwise, execution can be set with the `interpreter` annotation, like so:
+
+```yaml { interpreter=cat }
+config:
+  nested:
+    para: true
+```
+
+Non-shell scripts can also access environment variables, and are run from the current working directory:
+
+```sh
+export YOUR_NAME=enter your name
+```
+
+```javascript { name=echo-hello-js }
+console.log(`Hello, ${process.env.YOUR_NAME}, from ${__dirname}!`)
 ```
 
 ## Curl an image
@@ -159,11 +176,3 @@ With [`antonmedv/fx`](https://github.com/antonmedv/fx) you can inspect JSON file
 ```sh { terminalRows=20 }
 curl -s "https://api.marquee.activecove.com/getWeather?lat=52&lon=10" | fx
 ```
-
-## YAML
-
-```yaml
-config:
-  netsed:
-    para: true
-```
diff --git a/package.json b/package.json
index 17091716..07ab7359 100644
--- a/package.json
+++ b/package.json
@@ -623,7 +623,6 @@
     "test:format:fix": "prettier --write .",
     "test:unit": "vitest -c ./vitest.conf.ts",
     "test:e2e": "wdio run ./tests/e2e/wdio.conf.ts",
-    "pretest:e2e": "npm run download:binary",
     "watch": "npm run build:dev -- --watch",
     "vscode:prepublish": "npm run prepare-binary",
     "download:binary": "npm run prepare-binary",
diff --git a/src/client/components/configuration/annotations.ts b/src/client/components/configuration/annotations.ts
index 3659fd40..34aa19b1 100644
--- a/src/client/components/configuration/annotations.ts
+++ b/src/client/components/configuration/annotations.ts
@@ -145,6 +145,11 @@ export class Annotations extends LitElement {
       description: "Cell's canonical name for easy referencing in the CLI.",
       docs: 'https://docs.runme.dev/configuration#cell-options',
     },
+    interpreter: {
+      description: 'Script shebang line',
+      // FIXME: update docs link
+      docs: '',
+    },
     category: {
       description: 'Execute this code cell within a category.',
       docs: 'https://docs.runme.dev/configuration#run-all-cells-by-category',
@@ -446,6 +451,7 @@ export class Annotations extends LitElement {
             <div class="box">${this.renderTextFieldTabEntry('mimeType')}</div>
             <div class="box">${this.renderCategoryTabEntry('category')}</div>
             <div class="box">${this.renderTextFieldTabEntry('terminalRows')}</div>
+            <div class="box">${this.renderTextFieldTabEntry('interpreter')}</div>
           </div>
         </vscode-panel-view>
       </vscode-panels>
diff --git a/src/extension/executors/runner.ts b/src/extension/executors/runner.ts
index 1db290d4..23d754aa 100644
--- a/src/extension/executors/runner.ts
+++ b/src/extension/executors/runner.ts
@@ -35,7 +35,7 @@ import { toggleTerminal } from '../commands'
 import { NotebookCellOutputManager } from '../cell'
 
 import { closeTerminalByEnvID } from './task'
-import { getCellShellPath, parseCommandSeq, getCellCwd } from './utils'
+import { parseCommandSeq, getCellCwd, getCellProgram } from './utils'
 import { handleVercelDeployOutput, isVercelDeployScript } from './vercel'
 
 import type { IEnvironmentManager } from '.'
@@ -124,8 +124,10 @@ export async function executeRunner(
     }
   }
 
+  const { programName, commandMode } = getCellProgram(exec.cell, exec.cell.notebook, execKey)
+
   const program = await runner.createProgramSession({
-    programName: getCellShellPath(exec.cell, exec.cell.notebook, execKey) ?? execKey,
+    programName,
     environment,
     exec: execution,
     envs: Object.entries(envs).map(([k, v]) => `${k}=${v}`),
@@ -134,6 +136,8 @@ export async function executeRunner(
     tty: interactive,
     convertEol: !mimeType || mimeType === 'text/plain',
     storeLastOutput: true,
+    languageId: exec.cell.document.languageId,
+    commandMode,
   })
 
   context.subscriptions.push(program)
diff --git a/src/extension/executors/utils.ts b/src/extension/executors/utils.ts
index cf11f4ae..e3b14775 100644
--- a/src/extension/executors/utils.ts
+++ b/src/extension/executors/utils.ts
@@ -16,9 +16,10 @@ import {
 
 import { ENV_STORE } from '../constants'
 import { DEFAULT_PROMPT_ENV, OutputType } from '../../constants'
-import type { CellOutputPayload, Serializer } from '../../types'
+import type { CellOutputPayload, Serializer, ShellType } from '../../types'
 import { NotebookCellOutputManager } from '../cell'
 import { getAnnotations, getWorkspaceFolder } from '../utils'
+import { CommandMode } from '../grpc/runnerTypes'
 
 const ENV_VAR_REGEXP = /(\$\w+)/g
 /**
@@ -206,8 +207,8 @@ export async function retrieveShellCommand(
  * @param execKey Used as fallback in case `$SHELL` is not present
  */
 export function getSystemShellPath(): string | undefined
-export function getSystemShellPath(execKey: string | undefined): string | undefined
 export function getSystemShellPath(execKey: string): string
+export function getSystemShellPath(execKey?: string): string | undefined
 export function getSystemShellPath(execKey?: string): string | undefined {
   return process.env.SHELL ?? execKey
 }
@@ -236,6 +237,62 @@ export function getCellShellPath(
   return getSystemShellPath(execKey)
 }
 
+export function isShellLanguage(languageId: string): ShellType | undefined {
+  switch (languageId.toLowerCase()) {
+    case 'sh':
+    case 'bash':
+    case 'zsh':
+    case 'ksh':
+    case 'shell':
+    case 'shellscript':
+      return 'sh'
+
+    case 'bat':
+    case 'cmd':
+      return 'cmd'
+
+    case 'powershell':
+    case 'pwsh':
+      return 'powershell'
+
+    case 'fish':
+      return 'fish'
+
+    default:
+      return undefined
+  }
+}
+
+export function getCellProgram(
+  cell: NotebookCell | NotebookCellData | Serializer.Cell,
+  notebook: NotebookData | Serializer.Notebook | NotebookDocument,
+  execKey: string
+): { programName: string; commandMode: CommandMode } {
+  let result: { programName: string; commandMode: CommandMode }
+  const { interpreter } = getAnnotations(cell.metadata)
+
+  if (isShellLanguage(execKey)) {
+    const shellPath = getCellShellPath(cell, notebook, execKey) ?? execKey
+
+    result = {
+      programName: shellPath,
+      commandMode: CommandMode.INLINE_SHELL,
+    }
+  } else {
+    // TODO(mxs): make this configurable!!
+    result = {
+      programName: '',
+      commandMode: CommandMode.TEMP_FILE,
+    }
+  }
+
+  if (interpreter) {
+    result.programName = interpreter
+  }
+
+  return result
+}
+
 export async function getCellCwd(
   cell: NotebookCell | NotebookCellData | Serializer.Cell,
   notebook?: NotebookData | NotebookDocument | Serializer.Notebook,
diff --git a/src/extension/kernel.ts b/src/extension/kernel.ts
index d00703e0..bfe75ec6 100644
--- a/src/extension/kernel.ts
+++ b/src/extension/kernel.ts
@@ -39,9 +39,9 @@ import {
   processEnviron,
   isWindows,
   setNotebookCategories,
-  isShellLanguage,
   getTerminalRunmeId,
 } from './utils'
+import { isShellLanguage } from './executors/utils'
 import './wasm/wasm_exec.js'
 import { IRunner, IRunnerEnvironment } from './runner'
 import { executeRunner } from './executors/runner'
@@ -494,7 +494,7 @@ export class Kernel implements Disposable {
       // TODO(mxs): support windows shells
       !isWindows()
     ) {
-      const runScript = (execKey: string = 'sh') =>
+      const runScript = (key: string = execKey) =>
         executeRunner(
           this,
           this.context,
@@ -503,7 +503,7 @@ export class Kernel implements Disposable {
           runningCell,
           this.messaging,
           uuid,
-          execKey,
+          key,
           outputs,
           this.environment,
           environmentManager
@@ -513,9 +513,9 @@ export class Kernel implements Disposable {
           return false
         })
 
-      if (isShellLanguage(execKey)) {
+      if (isShellLanguage(execKey) || !(execKey in executor)) {
         successfulCellExecution = await runScript(execKey)
-      } else if (execKey in executor) {
+      } else {
         successfulCellExecution = await executor[execKey as keyof typeof executor].call(
           this,
           exec,
@@ -524,22 +524,23 @@ export class Kernel implements Disposable {
           runScript,
           environmentManager
         )
-      } else {
-        this.#shebangComingSoon.open()
-
-        successfulCellExecution = false
       }
-    } else {
+    } else if (execKey in executor) {
       /**
        * check if user is running experiment to execute shell via runme cli
        */
-      successfulCellExecution = await executor[execKey as keyof typeof executor]?.call(
+      successfulCellExecution = await executor[execKey as keyof typeof executor].call(
         this,
         exec,
         runningCell,
         outputs
       )
+    } else {
+      window.showErrorMessage('Cell language is not executable')
+
+      successfulCellExecution = false
     }
+
     TelemetryReporter.sendTelemetryEvent('cell.endExecute', {
       'cell.success': successfulCellExecution?.toString(),
     })
diff --git a/src/extension/provider/runmeTask.ts b/src/extension/provider/runmeTask.ts
index 64655de8..7f6daee3 100644
--- a/src/extension/provider/runmeTask.ts
+++ b/src/extension/provider/runmeTask.ts
@@ -24,7 +24,7 @@ import { getAnnotations, getWorkspaceEnvs, prepareCmdSeq } from '../utils'
 import { Serializer, RunmeTaskDefinition } from '../../types'
 import { SerializerBase } from '../serializer'
 import type { IRunner, IRunnerEnvironment, RunProgramOptions } from '../runner'
-import { getCellCwd, getCellShellPath, parseCommandSeq } from '../executors/utils'
+import { getCellCwd, getCellProgram, parseCommandSeq } from '../executors/utils'
 import { Kernel } from '../kernel'
 
 type TaskOptions = Pick<RunmeTaskDefinition, 'closeTerminalOnSuccess' | 'isBackground' | 'cwd'>
@@ -118,6 +118,12 @@ export class RunmeTaskProvider implements TaskProvider {
 
     const isBackground = options.isBackground || background
 
+    const { programName, commandMode } = getCellProgram(
+      cell,
+      notebook,
+      ('languageId' in cell && cell.languageId) || 'sh'
+    )
+
     const name = `${command}`
 
     const task = new Task(
@@ -145,7 +151,7 @@ export class RunmeTaskProvider implements TaskProvider {
         }
 
         const runOpts: RunProgramOptions = {
-          programName: getCellShellPath(cell, notebook) ?? 'sh',
+          programName,
           exec: {
             type: 'commands',
             commands: commands ?? [''],
@@ -156,6 +162,7 @@ export class RunmeTaskProvider implements TaskProvider {
           convertEol: true,
           envs: Object.entries(envs).map(([k, v]) => `${k}=${v}`),
           storeLastOutput: true,
+          commandMode,
         }
 
         const program = await runner.createProgramSession(runOpts)
diff --git a/src/extension/runner.ts b/src/extension/runner.ts
index b7821440..86c52714 100644
--- a/src/extension/runner.ts
+++ b/src/extension/runner.ts
@@ -6,12 +6,14 @@ import {
   type Disposable,
   type TerminalDimensions,
   EventEmitter,
+  window,
 } from 'vscode'
 import { RpcError } from '@protobuf-ts/runtime-rpc'
 
 import type { DisposableAsync } from '../types'
 
 import {
+  CommandMode,
   CreateSessionRequest,
   ExecuteRequest,
   ExecuteResponse,
@@ -51,6 +53,9 @@ export interface RunProgramOptions {
   background?: boolean
   convertEol?: boolean
   storeLastOutput?: boolean
+  languageId?: string
+  fileExtension?: string
+  commandMode?: CommandMode
 }
 
 export interface IRunner extends Disposable {
@@ -407,6 +412,17 @@ export class GrpcRunnerProgramSession implements IRunnerProgramSession {
 
     this.session.responses.onError((error) => {
       if (error instanceof RpcError) {
+        if (error.message.includes('invalid LanguageId')) {
+          window.showErrorMessage(
+            // eslint-disable-next-line max-len
+            'Unable to automatically execute cell. To execute this cell, set the "interpreter" field in the configuration foldout!'
+          )
+        }
+
+        if (error.message.includes('invalid ProgramName')) {
+          window.showErrorMessage(`Unable to locate interpreter "${this.opts.programName}"`)
+        }
+
         console.error(
           'RpcError occurred!',
           {
@@ -712,6 +728,9 @@ export class GrpcRunnerProgramSession implements IRunnerProgramSession {
     terminalDimensions,
     background,
     storeLastOutput,
+    fileExtension,
+    languageId,
+    commandMode,
   }: RunProgramOptions): ExecuteRequest {
     if (environment && !(environment instanceof GrpcRunnerEnvironment)) {
       throw new Error('Expected gRPC environment!')
@@ -729,6 +748,9 @@ export class GrpcRunnerProgramSession implements IRunnerProgramSession {
       ...(exec?.type === 'script' && { script: exec.script }),
       ...(terminalDimensions && { winsize: terminalDimensionsToWinsize(terminalDimensions) }),
       storeLastOutput,
+      fileExtension,
+      languageId,
+      commandMode,
     })
   }
 
diff --git a/src/extension/utils.ts b/src/extension/utils.ts
index 452a8e3a..1a924e22 100644
--- a/src/extension/utils.ts
+++ b/src/extension/utils.ts
@@ -21,13 +21,7 @@ import { v5 as uuidv5 } from 'uuid'
 import getPort from 'get-port'
 import dotenv from 'dotenv'
 
-import {
-  CellAnnotations,
-  CellAnnotationsErrorResult,
-  RunmeTerminal,
-  Serializer,
-  ShellType,
-} from '../types'
+import { CellAnnotations, CellAnnotationsErrorResult, RunmeTerminal, Serializer } from '../types'
 import { SafeCellAnnotationsSchema, CellAnnotationsSchema } from '../schema'
 import {
   AuthenticationProviders,
@@ -167,31 +161,6 @@ export function getKey(runningCell: vscode.TextDocument): string {
   return languageId
 }
 
-export function isShellLanguage(languageId: string): ShellType | undefined {
-  switch (languageId.toLowerCase()) {
-    case 'sh':
-    case 'bash':
-    case 'zsh':
-    case 'ksh':
-    case 'shell':
-      return 'sh'
-
-    case 'bat':
-    case 'cmd':
-      return 'cmd'
-
-    case 'powershell':
-    case 'pwsh':
-      return 'powershell'
-
-    case 'fish':
-      return 'fish'
-
-    default:
-      return undefined
-  }
-}
-
 /**
  * treat cells like a series of individual commands
  * which need to be executed in sequence
diff --git a/src/schema.ts b/src/schema.ts
index 37eeff6e..85eda581 100644
--- a/src/schema.ts
+++ b/src/schema.ts
@@ -74,6 +74,7 @@ export const AnnotationSchema = {
       return true
     }, 'mime type specification invalid format')
     .default('text/plain'),
+  interpreter: z.string().optional().default(''),
   cwd: z.string().nonempty().optional(),
   category: z.preprocess(
     (value) => (typeof value === 'string' ? cleanAnnotation(value, ',') : value),


Test patch:
diff --git a/tests/e2e/specs/basic.e2e.ts b/tests/e2e/specs/basic.e2e.ts
index b7818154..d84195ed 100644
--- a/tests/e2e/specs/basic.e2e.ts
+++ b/tests/e2e/specs/basic.e2e.ts
@@ -83,6 +83,28 @@ describe('Runme VS Code Extension', async () => {
       ])
     })
 
+    it('basic hello world python execution', async () => {
+      const cell = await notebook.getCell('print("Hello World")')
+
+      await cell.run()
+
+      expect(await cell.getCellOutput(OutputType.TerminalView)).toStrictEqual([
+        'Hello World\n'
+      ])
+    })
+
+    it('basic yaml example', async () => {
+      const cell = await notebook.getCell('config:\n  nested:\n    para: true')
+
+      await cell.run()
+
+      await tryExecuteCommand(await browser.getWorkbench(), 'focus active cell output')
+
+      expect(await cell.getCellOutput(OutputType.TerminalView)).toStrictEqual([
+        'config:\nnested:\npara: true\n'
+      ])
+    })
+
     it('more shell example', async () => {
       const cell = await notebook.getCell('echo "Foo 👀"\nsleep 2\necho "Bar 🕺"\nsleep 2\necho "Loo 🚀"')
       await cell.run()
diff --git a/tests/extension/commands/index.test.ts b/tests/extension/commands/index.test.ts
index daaefdf3..8ed9d0a2 100644
--- a/tests/extension/commands/index.test.ts
+++ b/tests/extension/commands/index.test.ts
@@ -67,6 +67,8 @@ vi.mock('../../../src/extension/runner', () => ({
   GrpcRunnerEnvironment: class { }
 }))
 
+vi.mock('../../../src/extension/grpc/runnerTypes', () => ({}))
+
 beforeEach(() => {
   vi.mocked(window.showWarningMessage).mockClear()
   vi.mocked(window.showInformationMessage).mockClear()
diff --git a/tests/extension/executors/utils.test.ts b/tests/extension/executors/utils.test.ts
index 0a83d7e9..3d68f6ec 100644
--- a/tests/extension/executors/utils.test.ts
+++ b/tests/extension/executors/utils.test.ts
@@ -11,9 +11,11 @@ import {
   parseCommandSeq,
   getCellShellPath,
   getSystemShellPath,
-  getCellCwd
+  getCellCwd,
+  isShellLanguage
 } from '../../../src/extension/executors/utils'
 import { getCmdSeq, getWorkspaceFolder, getAnnotations } from '../../../src/extension/utils'
+import { getCellProgram } from '../../../src/extension/executors/utils'
 
 const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
 
@@ -35,6 +37,16 @@ vi.mock('node:fs/promises', () => ({
   }
 }))
 
+const COMMAND_MODE_INLINE_SHELL = 1
+const COMMAND_MODE_TEMP_FILE = 2
+
+vi.mock('../../../src/extension/grpc/runnerTypes', () => ({
+  CommandMode: {
+    INLINE_SHELL: 1,
+    TEMP_FILE: 2,
+  }
+}))
+
 beforeEach(() => {
   vi.mocked(window.showInputBox).mockClear()
   vi.mocked(window.showErrorMessage).mockClear()
@@ -346,6 +358,54 @@ suite('getCellShellPath', () => {
   })
 })
 
+suite('isShellLanguage', () => {
+  test('usual suspects', () => {
+    for (const shell of ['bash', 'sh', 'fish', 'ksh', 'zsh', 'shell', 'bat', 'cmd', 'powershell', 'pwsh']) {
+      expect(isShellLanguage(shell)).toBeTruthy()
+    }
+  })
+})
+
+suite('getCellProgram', () => {
+  test('is inline shell for shell types', async () => {
+    for (const shell of ['bash', 'sh', 'fish', 'ksh', 'zsh', 'shell', 'bat', 'cmd', 'powershell', 'pwsh']) {
+      vi.mocked(getAnnotations).mockReturnValueOnce({} as any)
+
+      expect(getCellProgram({ metadata: { } } as any, {} as any, shell)).toStrictEqual({
+        commandMode: COMMAND_MODE_INLINE_SHELL,
+        programName: getSystemShellPath()
+      })
+    }
+  })
+
+  test('is temp file for non-shell types', async () => {
+    vi.mocked(getAnnotations).mockReturnValueOnce({} as any)
+
+    expect(getCellProgram({ metadata: { } } as any, {} as any, 'python')).toStrictEqual({
+      commandMode: COMMAND_MODE_TEMP_FILE,
+      programName: ''
+    })
+  })
+
+  test('respects custom interpreter in shell mode', async () => {
+    vi.mocked(getAnnotations).mockImplementationOnce(((x: any) => ({ interpreter: x.interpreter })) as any)
+
+    expect(getCellProgram({ metadata: { interpreter: 'fish' } } as any, {} as any, 'sh')).toStrictEqual({
+      commandMode: COMMAND_MODE_INLINE_SHELL,
+      programName: 'fish'
+    })
+  })
+
+  test('respects custom interpreter in temp file mode', async () => {
+    vi.mocked(getAnnotations).mockImplementationOnce(((x: any) => ({ interpreter: x.interpreter })) as any)
+
+    expect(getCellProgram({ metadata: { interpreter: 'bun' } } as any, {} as any, 'javascript')).toStrictEqual({
+      commandMode: COMMAND_MODE_TEMP_FILE,
+      programName: 'bun'
+    })
+  })
+})
+
 suite('getCellCwd', () => {
   const projectRoot = '/project'
   const mdFilePath = '/project/folder/DOC.md'
diff --git a/tests/extension/kernel.test.ts b/tests/extension/kernel.test.ts
index e0449fa6..7437f196 100644
--- a/tests/extension/kernel.test.ts
+++ b/tests/extension/kernel.test.ts
@@ -24,6 +24,8 @@ vi.mock('../../src/extension/executors/index.js', () => ({
 }))
 vi.mock('../../src/extension/runner', () => ({}))
 
+vi.mock('../../src/extension/grpc/runnerTypes', () => ({}))
+
 const getCells = (cnt: number, metadata: Record<string, any> = {}) => ([...new Array(cnt)]).map((_, i) => ({
   document: { getText: vi.fn().mockReturnValue(`Cell #${i}`) },
   notebook: {
@@ -208,7 +210,7 @@ suite('_doExecuteCell', () => {
 
   test('shows error window if language is not supported', async () => {
     const k = new Kernel({} as any)
-    k['runner'] = {} as any
+    k['runner'] = undefined
 
     k.createCellExecution = vi.fn().mockResolvedValue({
       start: vi.fn(),
@@ -221,10 +223,14 @@ suite('_doExecuteCell', () => {
       languageId: 'barfoo'
     } as any)
 
-    await k['_doExecuteCell']({
-      document: { uri: { fsPath: '/foo/bar' } },
-      metadata: { 'runme.dev/uuid': '849448b2-3c41-4323-920e-3098e71302ce' }
-    } as any)
+    try {
+      await k['_doExecuteCell']({
+        document: { uri: { fsPath: '/foo/bar' } },
+        metadata: { 'runme.dev/uuid': '849448b2-3c41-4323-920e-3098e71302ce' }
+      } as any)
+    } catch(e) {
+
+    }
 
     expect(TelemetryReporter.sendTelemetryEvent).toHaveBeenCalledWith(
       'cell.startExecute'
diff --git a/tests/extension/messages/cellOutput.test.ts b/tests/extension/messages/cellOutput.test.ts
index a2c676c8..b788ef6c 100644
--- a/tests/extension/messages/cellOutput.test.ts
+++ b/tests/extension/messages/cellOutput.test.ts
@@ -9,6 +9,8 @@ vi.mock('vscode')
 vi.mock('vscode-telemetry')
 vi.mock('../../../src/extension/runner', () => ({}))
 
+vi.mock('../../../src/extension/grpc/runnerTypes', () => ({}))
+
 suite('Handle CellOutput messages', () => {
 
     const mockOutput = (type: OutputType) => {
@@ -78,4 +80,4 @@ suite('Handle CellOutput messages', () => {
     test('Vercel cell output', async () => {
         return expectOutput(OutputType.vercel)
     })
-})
\ No newline at end of file
+})
diff --git a/tests/extension/provider/annotations.test.ts b/tests/extension/provider/annotations.test.ts
index bf6bd763..204fe66f 100644
--- a/tests/extension/provider/annotations.test.ts
+++ b/tests/extension/provider/annotations.test.ts
@@ -32,6 +32,7 @@ vi.mock('../../../src/extension/utils', () => ({
 }))
 
 vi.mock('../../../src/extension/runner', () => ({ }))
+vi.mock('../../../src/extension/grpc/runnerTypes', () => ({}))
 
 describe('Runme Annotations', () => {
   const kernel = new Kernel({} as any)
diff --git a/tests/extension/runner.test.ts b/tests/extension/runner.test.ts
index ec245a25..26337fd4 100644
--- a/tests/extension/runner.test.ts
+++ b/tests/extension/runner.test.ts
@@ -2,7 +2,8 @@ import path from 'node:path'
 
 import { vi, suite, test, expect, beforeEach } from 'vitest'
 import { type GrpcTransport } from '@protobuf-ts/grpc-transport'
-import { EventEmitter, NotebookCellKind, Position, tasks, commands, EndOfLine } from 'vscode'
+import { EventEmitter, NotebookCellKind, Position, tasks, commands, EndOfLine, window } from 'vscode'
+import { RpcError } from '@protobuf-ts/runtime-rpc'
 
 import {
   GrpcRunner,
@@ -199,6 +200,10 @@ suite('grpc Runner', () => {
   })
 
   suite('grpc program session', () => {
+    beforeEach(() => {
+      vi.mocked(window.showErrorMessage).mockClear()
+    })
+
     test('session dispose is called on runner dispose', async () => {
       const { runner, session, duplex } = await createNewSession()
 
@@ -559,6 +564,18 @@ suite('grpc Runner', () => {
       expect(writeListener).toBeCalledTimes(1)
       expect(writeListener).toBeCalledWith('test\n')
     })
+
+    test('grpc program failure gives info message', async () => {
+      const { duplex } = await createNewSession()
+
+      duplex._onError.fire(new RpcError('invalid LanguageId'))
+      expect(window.showErrorMessage).toBeCalledTimes(1)
+
+      vi.mocked(window.showErrorMessage).mockClear()
+
+      duplex._onError.fire(new RpcError('invalid ProgramName'))
+      expect(window.showErrorMessage).toBeCalledTimes(1)
+    })
   })
 })
 
diff --git a/tests/extension/serializer.test.ts b/tests/extension/serializer.test.ts
index e7ce4227..5f90487c 100644
--- a/tests/extension/serializer.test.ts
+++ b/tests/extension/serializer.test.ts
@@ -40,6 +40,7 @@ vi.mock('../../src/extension/languages', () => ({
   default: {
     fromContext: vi.fn(),
   },
+    NotebookData: class {}
 }))
 
 vi.mock('../../src/extension/utils', () => ({
diff --git a/tests/extension/utils.test.ts b/tests/extension/utils.test.ts
index 82692643..9e91e3f6 100644
--- a/tests/extension/utils.test.ts
+++ b/tests/extension/utils.test.ts
@@ -24,7 +24,6 @@ import {
   getNotebookCategories,
   getNamespacedMid,
   bootFile,
-  isShellLanguage,
   fileOrDirectoryExists,
   isMultiRootWorkspace,
   convertEnvList,
@@ -136,12 +135,6 @@ test('getKey', () => {
   } as any)).toBe('sh')
 })
 
-test('isShellLanguage', () => {
-  for (const shell of ['bash', 'sh', 'fish', 'ksh', 'zsh', 'shell', 'bat', 'cmd', 'powershell', 'pwsh']) {
-    expect(isShellLanguage(shell)).toBeTruthy()
-  }
-})
-
 suite('getCmdShellSeq', () => {
   test('one command', () => {
     const cellText = 'deno task start'
@@ -285,7 +278,8 @@ suite('#getAnnotations', () => {
         category: '',
         excludeFromRunAll: false,
         promptEnv: true,
-        'runme.dev/uuid': '48d86c43-84a4-469d-8c78-963513b0f9d0'
+        'runme.dev/uuid': '48d86c43-84a4-469d-8c78-963513b0f9d0',
+        interpreter: '',
       }
     )
   })
@@ -306,7 +300,8 @@ suite('#getAnnotations', () => {
       name: 'echo-hello',
       promptEnv: true,
       excludeFromRunAll: false,
-      category: ''
+      category: '',
+      interpreter: '',
     })
   })
 })