Repository: stateful/vscode-runme (ts)

Issue:
Code block name gets out of sync when edited
Please take a look at the following scenario. One of the example code cells looks like this:

<img width="698" alt="image" src="https://user-images.githubusercontent.com/250527/234953520-3486cce9-5200-47a8-83e8-ef7e699562d6.png">

Let's edit it according to:

<img width="630" alt="image" src="https://user-images.githubusercontent.com/250527/234953602-eaa220cb-cbb7-4511-a264-4db46377beff.png">

Now when you hit the "CLI button" the default name will be out of sync between extension (deserialize time) vs CLI (real-time):

<img width="1633" alt="image" src="https://user-images.githubusercontent.com/250527/234953811-797f276c-a637-4011-b9d6-50c024635074.png">


Pull request:
Update cell names on save
Fixes #461 

In the future we should probably return a more detailed response from the serialize request, and then use that to update the metadata.

Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a06204bd..e781a31a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -57,6 +57,8 @@ To get the code base, have [git](https://git-scm.com/downloads) installed and ru
 
 ```sh
 git clone git@github.com:stateful/vscode-runme.git
+cd vscode-runme
+nvm install
 ```
 
 optionally install the GitHub CLI to streamline downloading the WASM binary:
@@ -69,15 +71,13 @@ brew install gh
 
 make sure to configure your local npm to pull from Buf's registry (for GRPC dependencies)
 
-```sh
+```sh { name=configureNPM }
 npm config set @buf:registry https://buf.build/gen/npm/v1
 ```
 
-then ensure to install all project dependencies:
+then ensure to install all project dependencies.
 
-```sh
-cd vscode-runme
-nvm install
+```sh { name=setup }
 GITHUB_REF_NAME=$(git branch --show-current) GITHUB_TOKEN=$(gh auth token) npm install --include=dev
 ```
 
@@ -93,7 +93,7 @@ DOWNLOAD_URL=$(node .github/scripts/getLastStableRelease) TYPE=tar npm run prepa
 
 To compile all extension files, run:
 
-```sh
+```sh { name=build }
 npm run build
 ```
 
@@ -101,7 +101,7 @@ npm run build
 
 To test the project, run:
 
-```sh
+```sh { name=test }
 npm run test
 ```
 
diff --git a/__mocks__/vscode.ts b/__mocks__/vscode.ts
index 207b4b22..cb91f74c 100644
--- a/__mocks__/vscode.ts
+++ b/__mocks__/vscode.ts
@@ -25,7 +25,7 @@ export class Uri extends URI {
   static parse = vi.fn(super.parse)
 
   static joinPath = vi.fn((uri: Uri, ...paths: string[]) => {
-    return Uri.file(path.join(uri.fsPath, ...paths))
+    return super.file(path.join(uri.fsPath, ...paths))
   })
 }
 
@@ -222,6 +222,12 @@ export class EventEmitter<T> {
     this.listeners.forEach(l => l(data))
   }
 
+  async fireAsync(data: T) {
+    await Promise.all(
+      this.listeners.map(l => l(data))
+    )
+  }
+
   dispose() { }
 }
 
diff --git a/package.json b/package.json
index fca939e1..23edee4a 100644
--- a/package.json
+++ b/package.json
@@ -261,18 +261,6 @@
       {
         "title": "Runme",
         "properties": {
-          "runme.shell.interactive": {
-            "type": "boolean",
-            "scope": "machine",
-            "default": true,
-            "markdownDescription": "If enabled all shell scripts are run as interactive VS Code tasks, if set to `false` they are executed within the kernel and output is set in resulting cell."
-          },
-          "runme.shell.closeTerminalOnSuccess": {
-            "type": "boolean",
-            "scope": "machine",
-            "default": true,
-            "markdownDescription": "If enabled, the terminal closes if task succeeds running."
-          },
           "runme.flags.disableSaveRestriction": {
             "type": "boolean",
             "scope": "machine",
@@ -308,11 +296,10 @@
             "default": 60000,
             "markdownDescription": "Timeout until Runme will cancel the clone process."
           },
-          "runme.server.port": {
-            "type": "number",
+          "runme.server.customAddress": {
+            "type": "string",
             "scope": "machine",
-            "default": 7863,
-            "markdownDescription": "Port used by Runme server. (It requires the GRPC interface enabled)"
+            "markdownDescription": "Custom runme server address. This address will be used instead of launching the server at start. Use this only if you know what you are doing!"
           },
           "runme.server.binaryPath": {
             "type": "string",
diff --git a/src/constants.ts b/src/constants.ts
index 3b9e7ed0..f72ef8ae 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -88,3 +88,4 @@ export const LANGUAGES = new Map([
 ].map(([, source, dest]) => [source, dest]))
 
 export const SERVER_ADDRESS = 'localhost'
+export const SERVER_PORT = 7863
diff --git a/src/extension/commands/index.ts b/src/extension/commands/index.ts
index 86dc0972..affab2be 100644
--- a/src/extension/commands/index.ts
+++ b/src/extension/commands/index.ts
@@ -1,5 +1,4 @@
 import path from 'node:path'
-import os from 'node:os'
 
 import {
   NotebookCell, Uri, window, env, NotebookDocument, TextDocument, ViewColumn,
@@ -7,7 +6,7 @@ import {
 } from 'vscode'
 import { v4 as uuidv4 } from 'uuid'
 
-import { getBinaryPath, getTLSDir, getTLSEnabled, isNotebookTerminalEnabledForCell } from '../../utils/configuration'
+import { getTLSDir, getTLSEnabled, isNotebookTerminalEnabledForCell } from '../../utils/configuration'
 import { Kernel } from '../kernel'
 import { getAnnotations, getTerminalByCell, openFileAsRunmeNotebook } from '../utils'
 import RunmeServer from '../server/runmeServer'
@@ -78,8 +77,6 @@ export function runCLICommand(
 
     const envs: Record<string, string> = { }
 
-    const runmeExecutable = getBinaryPath(extensionBaseUri, os.platform()).fsPath
-
     if (grpcRunner) {
       envs['RUNME_SERVER_ADDR'] = server.address()
 
@@ -103,7 +100,7 @@ export function runCLICommand(
     })
 
     term.show(false)
-    term.sendText(`${runmeExecutable} run ${annotations.name} ${args.join(' ')}`)
+    term.sendText(`runme run ${annotations.name} ${args.join(' ')}`)
   }
 }
 
diff --git a/src/extension/executors/runner.ts b/src/extension/executors/runner.ts
index 99fde2c8..e4cbb6fc 100644
--- a/src/extension/executors/runner.ts
+++ b/src/extension/executors/runner.ts
@@ -20,7 +20,7 @@ import { ClientMessages } from '../../constants'
 import { ClientMessage } from '../../types'
 import { PLATFORM_OS } from '../constants'
 import { IRunner, IRunnerEnvironment, RunProgramExecution } from '../runner'
-import { getAnnotations, getCmdShellSeq, getTerminalByCell, prepareCmdSeq } from '../utils'
+import { getAnnotations, getCellRunmeId, getCmdShellSeq, getTerminalByCell, prepareCmdSeq } from '../utils'
 import { postClientMessage } from '../../utils/messaging'
 import { isNotebookTerminalEnabledForCell } from '../../utils/configuration'
 import { Kernel } from '../kernel'
@@ -71,7 +71,7 @@ export async function executeRunner(
 
   const cwd = path.dirname(runningCell.uri.fsPath)
 
-  const RUNME_ID = `${runningCell.fileName}:${exec.cell.index}`
+  const RUNME_ID = getCellRunmeId(exec.cell)
 
   let cellText = exec.cell.document.getText()
 
diff --git a/src/extension/extension.ts b/src/extension/extension.ts
index 2af20597..6fb1b672 100644
--- a/src/extension/extension.ts
+++ b/src/extension/extension.ts
@@ -2,6 +2,8 @@
 import { workspace, notebooks, commands, ExtensionContext, tasks, window, Uri } from 'vscode'
 import { TelemetryReporter } from 'vscode-telemetry'
 
+import { registerExtensionEnvironmentVariables } from '../utils/configuration'
+
 import { Kernel } from './kernel'
 import RunmeServer from './server/runmeServer'
 import RunmeServerError from './server/runmeServerError'
@@ -65,7 +67,10 @@ export class RunmeExtension {
         return window.showErrorMessage(`Failed to start Runme server. Reason: ${e.message}`)
       }
       TelemetryReporter.sendTelemetryErrorEvent('extension.server', { data: (e as Error).message })
-      return window.showErrorMessage('Failed to start Runme server, please try to reload the window')
+      return window.showErrorMessage(
+        'Failed to start Runme server, please try to reload the window. ' +
+        `Reason: ${(e as any).message}`
+      )
     }
 
     const treeViewer = new RunmeLauncherProvider(getDefaultWorkspace())
@@ -78,6 +83,8 @@ export class RunmeExtension {
 
     const codeLensProvider = new RunmeCodeLensProvider(serializer, runCLI, winCodeLensRunSurvey, runner, kernel)
 
+    registerExtensionEnvironmentVariables(context)
+
     context.subscriptions.push(
       kernel,
       serializer,
@@ -144,7 +151,7 @@ export class RunmeExtension {
         const bootFile = new TextDecoder().decode(await workspace.fs.readFile(startupFileUri))
         const bootFileUri = Uri.joinPath(workspace.workspaceFolders[0].uri, bootFile)
         await workspace.fs.delete(startupFileUri)
-        await commands.executeCommand('vscode.open', bootFileUri)
+        await commands.executeCommand('vscode.openWith', bootFileUri, Kernel.type)
       }
     }
   }
diff --git a/src/extension/grpc/serializerTypes.ts b/src/extension/grpc/serializerTypes.ts
index c83101b3..561d9bca 100644
--- a/src/extension/grpc/serializerTypes.ts
+++ b/src/extension/grpc/serializerTypes.ts
@@ -1,11 +1,1 @@
-import {
-  DeserializeRequest,
-  SerializeRequest,
-  Notebook,
-} from '@buf/stateful_runme.community_timostamm-protobuf-ts/runme/parser/v1/parser_pb'
-
-export {
-  DeserializeRequest,
-  SerializeRequest,
-  Notebook,
-}
+export * from '@buf/stateful_runme.community_timostamm-protobuf-ts/runme/parser/v1/parser_pb'
diff --git a/src/extension/kernel.ts b/src/extension/kernel.ts
index 378e48d0..7fa3fdb4 100644
--- a/src/extension/kernel.ts
+++ b/src/extension/kernel.ts
@@ -11,7 +11,7 @@ import {
   NotebookEdit,
   NotebookDocument,
   env,
-  Uri
+  Uri,
 } from 'vscode'
 import { TelemetryReporter } from 'vscode-telemetry'
 
@@ -56,7 +56,7 @@ export class Kernel implements Disposable {
   protected cellManager = new NotebookCellManager(this.#controller)
 
   constructor(
-    protected context: ExtensionContext,
+    protected context: ExtensionContext
   ) {
     const config = workspace.getConfiguration('runme.experiments')
     this.#experiments.set('grpcSerializer', config.get<boolean>('grpcSerializer', true))
@@ -107,6 +107,7 @@ export class Kernel implements Disposable {
     }
     const isReadme = uri.fsPath.toUpperCase().includes('README')
     const hashed = hashDocumentUri(uri.toString())
+
     TelemetryReporter.sendTelemetryEvent('notebook.save', {
       'notebook.hashedUri': hashed,
       'notebook.isReadme': isReadme.toString(),
diff --git a/src/extension/serializer.ts b/src/extension/serializer.ts
index bd4e6561..8f729cc4 100644
--- a/src/extension/serializer.ts
+++ b/src/extension/serializer.ts
@@ -13,6 +13,8 @@ import {
   NotebookEdit,
   NotebookDocumentChangeEvent,
   Disposable,
+  NotebookDocument,
+  CancellationTokenSource,
 } from 'vscode'
 import { v4 as uuidv4 } from 'uuid'
 import { GrpcTransport } from '@protobuf-ts/grpc-transport'
@@ -45,6 +47,9 @@ export abstract class SerializerBase implements NotebookSerializer, Disposable {
     this.disposables.push(
       workspace.onDidChangeNotebookDocument(
         this.handleNotebookChanged.bind(this)
+      ),
+      workspace.onDidSaveNotebookDocument(
+        this.handleNotebookSaved.bind(this)
       )
     )
   }
@@ -80,6 +85,32 @@ export abstract class SerializerBase implements NotebookSerializer, Disposable {
     })
   }
 
+  protected async handleNotebookSaved({ uri, cellAt }: NotebookDocument) {
+    // update changes in metadata
+    const bytes = await workspace.fs.readFile(uri)
+    const deserialized = await this.deserializeNotebook(bytes, new CancellationTokenSource().token)
+
+    const notebookEdits = deserialized.cells.flatMap((updatedCell, i) => {
+      const updatedName = (updatedCell.metadata as Serializer.Metadata|undefined)?.['runme.dev/name']
+      if (!updatedName) {
+        return []
+      }
+
+      const oldCell = cellAt(i)
+      return [
+        NotebookEdit.updateCellMetadata(i, {
+          ...oldCell.metadata || {},
+          'runme.dev/name': updatedName,
+        } as Serializer.Metadata)
+      ]
+    })
+
+    const edit = new WorkspaceEdit()
+    edit.set(uri, notebookEdits)
+
+    await workspace.applyEdit(edit)
+  }
+
   public static addCellUuid(
     metadata: Serializer.Metadata | undefined
   ): {
diff --git a/src/extension/server/runmeServer.ts b/src/extension/server/runmeServer.ts
index b0e1ff1f..13d8f137 100644
--- a/src/extension/server/runmeServer.ts
+++ b/src/extension/server/runmeServer.ts
@@ -11,7 +11,14 @@ import {
   HealthCheckResponse_ServingStatus
 } from '../grpc/healthTypes'
 import { SERVER_ADDRESS } from '../../constants'
-import { enableServerLogs, getBinaryPath, getPortNumber, getTLSDir, getTLSEnabled } from '../../utils/configuration'
+import {
+  enableServerLogs,
+  getBinaryPath,
+  getCustomServerAddress,
+  getPortNumber,
+  getTLSDir,
+  getTLSEnabled
+} from '../../utils/configuration'
 import { isPortAvailable } from '../utils'
 import { HealthClient } from '../grpc/client'
 
@@ -39,6 +46,7 @@ class RunmeServer implements Disposable {
     #disposables: Disposable[] = []
     #transport?: GrpcTransport
     #serverDisposables: Disposable[] = []
+    #forceExternalServer: boolean
 
     readonly #onClose = this.register(new EventEmitter<{ code: number|null }>())
     readonly #onTransportReady = this.register(new EventEmitter<{ transport: GrpcTransport }>())
@@ -49,7 +57,7 @@ class RunmeServer implements Disposable {
     constructor(
       extBasePath: Uri,
       options: IServerConfig,
-      protected readonly externalServer: boolean,
+      externalServer: boolean,
       protected readonly enableRunner = false
     ) {
       this.#port = getPortNumber()
@@ -59,6 +67,7 @@ class RunmeServer implements Disposable {
       this.#maxNumberOfIntents = options.maxNumberOfIntents
       this.#acceptsIntents = options.acceptsConnection?.intents || 50
       this.#acceptsInterval = options.acceptsConnection?.interval || 200
+      this.#forceExternalServer = externalServer
     }
 
     dispose() {
@@ -98,14 +107,22 @@ class RunmeServer implements Disposable {
     }
 
     address() {
-      return `${SERVER_ADDRESS}:${this.#port}`
+      return getCustomServerAddress() || `${SERVER_ADDRESS}:${this.#port}`
+    }
+
+    private get externalServer(): boolean {
+      return !!(getCustomServerAddress() || this.#forceExternalServer)
     }
 
     private static async getTLS(tlsDir: string) {
-      const certPEM = await fs.readFile(path.join(tlsDir, 'cert.pem'))
-      const privKeyPEM = await fs.readFile(path.join(tlsDir, 'key.pem'))
+      try {
+        const certPEM = await fs.readFile(path.join(tlsDir, 'cert.pem'))
+        const privKeyPEM = await fs.readFile(path.join(tlsDir, 'key.pem'))
 
-      return { certPEM, privKeyPEM }
+        return { certPEM, privKeyPEM }
+      } catch(e: any) {
+        throw new RunmeServerError('Unable to read TLS files', e)
+      }
     }
 
     protected async channelCredentials(): Promise<ChannelCredentials> {
diff --git a/src/extension/server/runmeServerError.ts b/src/extension/server/runmeServerError.ts
index c49d9a6f..a5945516 100644
--- a/src/extension/server/runmeServerError.ts
+++ b/src/extension/server/runmeServerError.ts
@@ -1,8 +1,11 @@
 class RunmeServerError extends Error {
-    constructor(message: string) {
-        super(message)
-        this.name = 'RunmeServerError'
+    constructor(message: string, err?: Error) {
+      if (err) {
+        message += `\nCaused by: ${err?.message}`
+      }
+      super(message)
+      this.name = 'RunmeServerError'
     }
 }
 
-export default RunmeServerError
\ No newline at end of file
+export default RunmeServerError
diff --git a/src/extension/utils.ts b/src/extension/utils.ts
index d0780c5f..fc959ef9 100644
--- a/src/extension/utils.ts
+++ b/src/extension/utils.ts
@@ -36,7 +36,6 @@ const HASH_PREFIX_REGEXP = /^\s*\#\s*/g
 export function getAnnotations(cell: vscode.NotebookCell): CellAnnotations
 export function getAnnotations(metadata?: Serializer.Metadata): CellAnnotations
 export function getAnnotations(raw: unknown): CellAnnotations | undefined {
-  const config = vscode.workspace.getConfiguration('runme.shell')
   const metadataFromCell = raw as vscode.NotebookCell
   let metadata = raw as Serializer.Metadata
 
@@ -45,8 +44,6 @@ export function getAnnotations(raw: unknown): CellAnnotations | undefined {
   }
 
   const schema = {
-    interactive: config.get<boolean>('interactive'),
-    closeTerminalOnSuccess: config.get<boolean>('closeTerminalOnSuccess'),
     ...metadata,
     name:
       metadata.name ||
@@ -60,7 +57,6 @@ export function getAnnotations(raw: unknown): CellAnnotations | undefined {
 }
 
 export function validateAnnotations(cell: NotebookCell): CellAnnotationsErrorResult {
-  const config = vscode.workspace.getConfiguration('runme.shell')
   let metadata = cell as Serializer.Metadata
 
   if (cell.metadata) {
@@ -68,8 +64,6 @@ export function validateAnnotations(cell: NotebookCell): CellAnnotationsErrorRes
   }
 
   const schema = {
-    interactive: config.get<boolean>('interactive'),
-    closeTerminalOnSuccess: config.get<boolean>('closeTerminalOnSuccess'),
     ...metadata,
     name:
       metadata.name ||
@@ -99,9 +93,23 @@ export function getTerminalRunmeId(t: vscode.Terminal): string|undefined {
     ?? undefined
 }
 
+export function getCellRunmeId(cell: vscode.NotebookCell) {
+  return getCellUUID(cell)
+}
+
+function getCellUUID(cell: vscode.NotebookCell): string {
+  if (cell.kind !== vscode.NotebookCellKind.Code) {
+    throw new Error('Cannot get cell UUID for non-code cell!')
+  }
+
+  return getAnnotations(cell)['runme.dev/uuid']!
+}
+
 export function getTerminalByCell(cell: vscode.NotebookCell): RunmeTerminal | undefined {
+  const RUNME_ID = getCellRunmeId(cell)
+
   return vscode.window.terminals.find((t) => {
-    return getTerminalRunmeId(t) === `${cell.document.fileName}:${cell.index}`
+    return getTerminalRunmeId(t) === RUNME_ID
   }) as RunmeTerminal | undefined
 }
 
diff --git a/src/schema.ts b/src/schema.ts
index 56610448..1d2026a7 100644
--- a/src/schema.ts
+++ b/src/schema.ts
@@ -63,10 +63,10 @@ export const AnnotationSchema = {
 export const SafeCellAnnotationsSchema = z.object({
     ...AnnotationSchema,
     background: falseyBoolean(false),
-    interactive: falseyBoolean(false),
+    interactive: falseyBoolean(true),
     closeTerminalOnSuccess: falseyBoolean(true),
 })
 
 export const CellAnnotationsSchema = z.object({
     ...AnnotationSchema
-})
\ No newline at end of file
+})
diff --git a/src/types.ts b/src/types.ts
index 69ab4efd..28e4e4bf 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -4,6 +4,7 @@ import { z } from 'zod'
 import { OutputType, ClientMessages } from './constants'
 import { SafeCellAnnotationsSchema } from './schema'
 import type { IRunnerProgramSession } from './extension/runner'
+import type * as Grpc from './extension/grpc/serializerTypes'
 
 export namespace Serializer {
   export type Notebook = {
@@ -11,19 +12,10 @@ export namespace Serializer {
     metadata?: Metadata
   }
 
-  type TextPosition = number
-
-  export type TextRange = {
-    start: TextPosition
-    end: TextPosition
-  }
-
-  export type Cell = {
+  export type Cell = Omit<Grpc.Cell, 'kind'|'metadata'|'languageId'> & {
     metadata?: Metadata
+    kind: NotebookCellKind
     languageId?: string
-    value: string
-    textRange?: Serializer.TextRange
-    kind: NotebookCellKind.Markup | NotebookCellKind.Code
   }
 
   export interface Wasm {
diff --git a/src/utils/configuration.ts b/src/utils/configuration.ts
index da32fe44..55a440fe 100644
--- a/src/utils/configuration.ts
+++ b/src/utils/configuration.ts
@@ -1,11 +1,12 @@
 import path from 'node:path'
 import os from 'node:os'
 
-import { NotebookCell, Uri, workspace } from 'vscode'
+import { ExtensionContext, NotebookCell, Uri, workspace } from 'vscode'
 import { z } from 'zod'
 import { v4 as uuidv4 } from 'uuid'
 
 import { getAnnotations, isWindows } from '../extension/utils'
+import { SERVER_PORT } from '../constants'
 
 const SERVER_SECTION_NAME = 'runme.server'
 const TERMINAL_SECTION_NAME= 'runme.terminal'
@@ -16,12 +17,10 @@ type NotebookTerminalValue = keyof typeof configurationSchema.notebookTerminal
 
 const configurationSchema = {
     server: {
-        port: z
-            .number()
-            .positive()
-            .min(7070)
-            .max(9090)
-            .default(7863),
+        customAddress: z
+            .string()
+            .nonempty()
+            .optional(),
         binaryPath: z
             .string()
             .optional(),
@@ -80,7 +79,11 @@ const getCodeLensConfigurationValue = <T>(configName: keyof typeof configuration
 }
 
 const getPortNumber = (): number => {
-    return getServerConfigurationValue<number>('port', 7863)
+  return SERVER_PORT
+}
+
+const getCustomServerAddress = (): string|undefined => {
+  return getServerConfigurationValue<string|undefined>('customAddress', undefined)
 }
 
 const getTLSEnabled = (): boolean => {
@@ -148,6 +151,15 @@ const getCodeLensEnabled = (): boolean => {
   return getCodeLensConfigurationValue<boolean>('enable', true)
 }
 
+const registerExtensionEnvironmentVariables = (context: ExtensionContext): void => {
+  context.environmentVariableCollection.prepend(
+    'PATH',
+    path.dirname(
+      getBinaryPath(context.extensionUri, os.platform()).fsPath
+    ) + (isWindows() ? ';' : ':'),
+  )
+}
+
 export {
     getPortNumber,
     getBinaryPath,
@@ -161,4 +173,6 @@ export {
     getNotebookTerminalFontSize,
     getNotebookTerminalRows,
     getCodeLensEnabled,
+    registerExtensionEnvironmentVariables,
+    getCustomServerAddress,
 }


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index c34ba7bb..fafadaaa 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -9,21 +9,19 @@ jobs:
     runs-on: ${{ matrix.os }}
     steps:
       - uses: actions/checkout@v3
-      - uses: actions/setup-node@v3
+      - name: 🧪 Setup and Test with Runme
+        uses: coactions/setup-xvfb@v1
         with:
-          node-version: 18
-      - name: 📦 Install Dependencies
-        run: npm install
+          run: |
+            npm i -g runme
+            npx runme run --filename=CONTRIBUTING.md configureNPM
+            npx runme run --filename=CONTRIBUTING.md setup
+            npx runme run --filename=CONTRIBUTING.md build
+            npx runme run --filename=CONTRIBUTING.md test
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-      - name: 🏗 Build Project
-        run: npm run build:dev
-      - name: 🧪 Run Tests
-        uses: GabrielBB/xvfb-action@v1
-        with:
-          run: npm test
-          options: "-screen 0 1600x1200x24"
-      - uses: actions/upload-artifact@v3
+      - name: 🔼 Upload Artifacts
+        uses: actions/upload-artifact@v3
         if: failure()
         with:
           name: screenshots
diff --git a/tests/extension/commands/index.test.ts b/tests/extension/commands/index.test.ts
index c7534932..bcacc6d0 100644
--- a/tests/extension/commands/index.test.ts
+++ b/tests/extension/commands/index.test.ts
@@ -119,7 +119,7 @@ test('runCLICommand', async () => {
 
   await runCLICommand({} as any, false, {} as any, {} as any)(cell)
   expect(vi.mocked((terminal as any).sendText)).toHaveBeenCalledWith(
-    '/bin/runme run foo-bar --chdir="/foo/bar" --filename="README.md"'
+    'runme run foo-bar --chdir="/foo/bar" --filename="README.md"'
   )
 })
 
diff --git a/tests/extension/configuration.test.ts b/tests/extension/configuration.test.ts
index 6e8ec074..9ca32129 100644
--- a/tests/extension/configuration.test.ts
+++ b/tests/extension/configuration.test.ts
@@ -14,6 +14,7 @@ import {
   getNotebookTerminalFontSize,
   getCodeLensEnabled
 } from '../../src/utils/configuration'
+import { SERVER_PORT } from '../../src/constants'
 
 vi.mock('../../src/extension/grpc/client', () => ({}))
 vi.mock('../../src/extension/grpc/runnerTypes', () => ({}))
@@ -100,9 +101,8 @@ suite('Configuration', () => {
     })
 
     test('Should use a valid specified port number', () => {
-        SETTINGS_MOCK.port = 8080
         const portNumber = getPortNumber()
-        expect(portNumber).toStrictEqual(8080)
+        expect(portNumber).toStrictEqual(SERVER_PORT)
     })
 
     test('Should disable server logs with an invalid value', () => {
diff --git a/tests/extension/extension.test.ts b/tests/extension/extension.test.ts
index 0e241f98..05b7e686 100644
--- a/tests/extension/extension.test.ts
+++ b/tests/extension/extension.test.ts
@@ -47,9 +47,18 @@ test('initializes all providers', async () => {
   vi.mocked(workspace.getConfiguration).mockReturnValue({
     get: vi.fn((config: string) => configValues[config])
   } as any)
-  vi.mocked(Uri.joinPath).mockReturnValue('/foo/bar' as any)
+  const dummyFilePath = Uri.file('/foo/bar')
+  vi.mocked(Uri.joinPath).mockReturnValue(dummyFilePath)
   RunmeServer['getTLS'] = vi.fn().mockResolvedValue({ privKeyPEM: testPrivKeyPEM, certPEM: testCertPEM })
-  const context: any = { subscriptions: [], extensionUri: { fsPath: '/foo/bar' } }
+  const context: any = {
+    subscriptions: [],
+    extensionUri: { fsPath: '/foo/bar' },
+    environmentVariableCollection: {
+      prepend: vi.fn(),
+      append: vi.fn(),
+      replace: vi.fn(),
+    }
+  }
   const ext = new RunmeExtension()
   await ext.initialize(context)
   expect(notebooks.registerNotebookCellStatusBarItemProvider).toBeCalledTimes(6)
@@ -58,7 +67,7 @@ test('initializes all providers', async () => {
   expect(window.registerTreeDataProvider).toBeCalledTimes(1)
   expect(window.registerUriHandler).toBeCalledTimes(1)
 
-  expect(commands.executeCommand).toBeCalledWith('vscode.open', '/foo/bar')
-  expect(workspace.fs.stat).toBeCalledWith('/foo/bar')
-  expect(workspace.fs.delete).toBeCalledWith('/foo/bar')
+  expect(commands.executeCommand).toBeCalledWith('vscode.openWith', dummyFilePath, 'runme')
+  expect(workspace.fs.stat).toBeCalledWith(dummyFilePath)
+  expect(workspace.fs.delete).toBeCalledWith(dummyFilePath)
 })
diff --git a/tests/extension/schema.test.ts b/tests/extension/schema.test.ts
index 729ae378..74597da0 100644
--- a/tests/extension/schema.test.ts
+++ b/tests/extension/schema.test.ts
@@ -93,7 +93,7 @@ suite('AnnotationSchema', () => {
         test('Should add safe defaults for invalid values', () => {
             const expectedOutput = {
                 background: false,
-                interactive: false,
+                interactive: true,
                 closeTerminalOnSuccess: true
             }
             const parseResult = SafeCellAnnotationsSchema.safeParse(
@@ -119,7 +119,7 @@ suite('AnnotationSchema', () => {
                 const { background, closeTerminalOnSuccess, interactive, mimeType, name } = parseResult.data
                 expect(background).toBeFalsy()
                 expect(closeTerminalOnSuccess).toBeTruthy()
-                expect(interactive).toBeFalsy()
+                expect(interactive).toBeTruthy()
                 expect(mimeType).toStrictEqual('text/plain')
                 expect(name).toBe('')
             }
@@ -151,10 +151,10 @@ suite('AnnotationSchema', () => {
                 const { background, closeTerminalOnSuccess, interactive, mimeType, name } = parseResult.data
                 expect(background).toBeFalsy()
                 expect(closeTerminalOnSuccess).toBeTruthy()
-                expect(interactive).toBeFalsy()
+                expect(interactive).toBeTruthy()
                 expect(mimeType).toStrictEqual('text/plain')
                 expect(name).toBe('')
             }
         })
     })
-})
\ No newline at end of file
+})
diff --git a/tests/extension/serializer.test.ts b/tests/extension/serializer.test.ts
index 343484d9..c76bc12e 100644
--- a/tests/extension/serializer.test.ts
+++ b/tests/extension/serializer.test.ts
@@ -1,8 +1,10 @@
-import { window } from 'vscode'
-import { expect, vi, it, describe } from 'vitest'
+import { NotebookDocument, NotebookEdit, window, workspace } from 'vscode'
+import { expect, vi, it, describe, beforeEach } from 'vitest'
 
 import { WasmSerializer } from '../../src/extension/serializer'
 import { canEditFile } from '../../src/extension/utils'
+import type { Kernel } from '../../src/extension/kernel'
+import { EventEmitter, Uri } from '../../__mocks__/vscode'
 
 globalThis.Go = vi.fn()
 globalThis.Runme = { serialize: vi.fn().mockResolvedValue('Hello World!') }
@@ -18,10 +20,17 @@ vi.mock('vscode', () => ({
     },
     Uri: { joinPath: vi.fn().mockReturnValue('/foo/bar') },
     workspace: {
-        fs: { readFile: vi.fn().mockReturnValue(new Promise(() => {})) },
+        fs: { readFile: vi.fn().mockResolvedValue({}) },
         onDidChangeNotebookDocument: vi.fn().mockReturnValue({ dispose: vi.fn() }),
+        onDidSaveNotebookDocument: vi.fn().mockReturnValue({ dispose: vi.fn() }),
+        applyEdit: vi.fn(),
     },
-    commands: { executeCommand: vi.fn() }
+    commands: { executeCommand: vi.fn() },
+    WorkspaceEdit: Map<Uri, NotebookEdit[]>,
+    NotebookEdit: {
+      updateCellMetadata: (i: number, metadata: any) => ({ i, metadata, type: 'updateCellMetadata' }),
+    },
+    CancellationTokenSource: vi.fn(),
 }))
 
 vi.mock('../../src/extension/utils', () => ({
@@ -29,6 +38,65 @@ vi.mock('../../src/extension/utils', () => ({
     initWasm: vi.fn()
 }))
 
+function newKernel(): Kernel {
+  return { } as unknown as Kernel
+}
+
+describe('SerializerBase', () => {
+  const context: any = {
+    extensionUri: { fsPath: '/foo/bar' }
+  }
+
+  describe('handleNotebookSaved', () => {
+    const _onDidSaveNotebookDocument = new EventEmitter<NotebookDocument>()
+
+    beforeEach(() => {
+      vi.mocked(workspace.onDidSaveNotebookDocument).mockImplementation(l => _onDidSaveNotebookDocument.event(l))
+
+      vi.mocked(workspace.applyEdit).mockClear()
+    })
+
+    it('updates cell names on save', async () => {
+      const s = new WasmSerializer(context, newKernel())
+
+      s['deserializeNotebook'] = vi.fn(() => ({
+        cells: [
+          {
+            metadata: {
+              'runme.dev/name': 'newName',
+              'interactive': true,
+            }
+          }
+        ],
+      }) as any)
+
+      const uri = Uri.file('/foo/bar')
+
+      await _onDidSaveNotebookDocument.fireAsync({
+        uri,
+        cellAt: () => ({ metadata: { 'runme.dev/name': 'oldName', 'interactive': false }}),
+      } as any)
+
+      expect(workspace.applyEdit).toHaveBeenCalledOnce()
+
+      const edit = vi.mocked(workspace.applyEdit).mock.calls[0][0]
+      expect(edit).toBeTruthy()
+
+      const edits = edit.get(uri)
+      expect(edits).toHaveLength(1)
+
+      expect(edits[0]).toStrictEqual({
+        i: 0,
+        type: 'updateCellMetadata',
+        metadata: {
+          interactive: false,
+          'runme.dev/name': 'newName',
+        }
+      })
+    })
+  })
+})
+
 describe('WasmSerializer', () => {
     const context: any = {
         extensionUri: { fsPath: '/foo/bar' }
@@ -36,7 +104,7 @@ describe('WasmSerializer', () => {
 
     describe('serializeNotebook', () => {
         it('fails when notebook is not active', async () => {
-            const s = new WasmSerializer(context)
+            const s = new WasmSerializer(context, newKernel())
             await expect(() => s.serializeNotebook({} as any, {} as any))
                 .rejects.toThrow(/not active/)
         })
@@ -44,7 +112,7 @@ describe('WasmSerializer', () => {
         it('prevents saving if canEditFile returns false', async () => {
             // @ts-ignore readonly
             window.activeNotebookEditor = {} as any
-            const s = new WasmSerializer(context)
+            const s = new WasmSerializer(context, newKernel())
             await expect(() => s.serializeNotebook({} as any, {} as any))
                 .rejects.toThrow(/saving non version controlled notebooks is disabled/)
         })
@@ -53,7 +121,7 @@ describe('WasmSerializer', () => {
             // @ts-ignore readonly
             window.activeNotebookEditor = {} as any
             vi.mocked(canEditFile).mockResolvedValue(true)
-            const s = new WasmSerializer(context)
+            const s = new WasmSerializer(context, newKernel())
             // @ts-ignore readonly
             s['ready'] = Promise.reject('ups')
             await expect(() => s.serializeNotebook({} as any, {} as any)).rejects.toThrow(/ups/)
@@ -63,7 +131,7 @@ describe('WasmSerializer', () => {
             // @ts-ignore readonly
             window.activeNotebookEditor = {} as any
             vi.mocked(canEditFile).mockResolvedValue(true)
-            const s = new WasmSerializer(context)
+            const s = new WasmSerializer(context, newKernel())
             // @ts-ignore readonly
             s['ready'] = Promise.resolve()
             expect(Buffer.from(await s.serializeNotebook({} as any, {} as any)))
diff --git a/tests/extension/utils.test.ts b/tests/extension/utils.test.ts
index 50da2031..09306d7e 100644
--- a/tests/extension/utils.test.ts
+++ b/tests/extension/utils.test.ts
@@ -1,5 +1,6 @@
 import vscode, { commands, workspace } from 'vscode'
 import { expect, vi, test, beforeEach, beforeAll, afterAll, suite } from 'vitest'
+import { v4 } from 'uuid'
 
 import {
   getTerminalByCell,
@@ -21,28 +22,39 @@ import { CellAnnotations } from '../../src/types'
 vi.mock('../../src/extension/grpc/client', () => ({}))
 vi.mock('../../src/extension/grpc/runnerTypes', () => ({}))
 
-vi.mock('vscode', () => ({
-  default: {
-    window: {
-      terminals: [
-        { creationOptions: { env: {} } },
-        { creationOptions: { env: { RUNME_ID: 'foobar:123' } } }
-      ]
+vi.mock('vscode', async () => {
+  const { v4 } = await vi.importActual('uuid') as typeof import('uuid')
+
+  const uuid1 = v4()
+  const uuid2 = v4()
+
+  return ({
+    default: {
+      window: {
+        terminals: [
+          { creationOptions: { env: { RUNME_ID: uuid1 } }, name: `echo hello (RUNME_ID: ${uuid1})` },
+          { creationOptions: { env: { RUNME_ID: uuid2 } }, name: `echo hi (RUNME_ID: ${uuid2})` }
+        ]
+      },
+      workspace: {
+        getConfiguration: vi.fn()
+      },
+      env: {
+        machineId: 'test-machine-id'
+      },
+      NotebookCellKind: {
+        Markup: 1,
+        Code: 2,
+      }
     },
     workspace: {
       getConfiguration: vi.fn()
     },
-    env: {
-      machineId: 'test-machine-id'
+    commands: {
+      executeCommand: vi.fn()
     },
-  },
-  workspace: {
-    getConfiguration: vi.fn()
-  },
-  commands: {
-    executeCommand: vi.fn()
-  },
-}))
+  })
+})
 vi.mock('vscode-telemetry')
 
 const PATH = process.env.PATH
@@ -53,22 +65,23 @@ beforeAll(() => {
 afterAll(() => { process.env.PATH = PATH })
 
 test('isInteractive', () => {
-  // when set to false in configutaration
-  vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: vi.fn().mockReturnValue(false) } as any)
-  expect(getAnnotations({ metadata: {} } as any).interactive).toBe(false)
-  expect(getAnnotations({ metadata: {} } as any).interactive).toBe(false)
   expect(getAnnotations({ metadata: { interactive: 'true' } } as any).interactive).toBe(true)
-
-  vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: vi.fn().mockReturnValue(true) } as any)
-  expect(getAnnotations({ metadata: {} } as any).interactive).toBe(true)
-  expect(getAnnotations({ metadata: {} } as any).interactive).toBe(true)
+  expect(getAnnotations({ metadata: { interactive: 'false' } } as any).interactive).toBe(false)
+  expect(getAnnotations({ metadata: { } } as any).interactive).toBe(true)
 })
 
 test('getTerminalByCell', () => {
-  expect(getTerminalByCell({ document: { fileName: 'foo' }, index: 42} as any))
-    .toBe(undefined)
-  expect(getTerminalByCell({ document: { fileName: 'foobar' }, index: 123} as any))
-    .not.toBe(undefined)
+  expect(getTerminalByCell({
+    metadata: { 'runme.dev/uuid': vscode.window.terminals[0].creationOptions['env'].RUNME_ID },
+    kind: 2,
+  } as any))
+    .toBeTruthy()
+
+    expect(getTerminalByCell({
+      metadata: { 'runme.dev/uuid': v4() },
+      kind: 2,
+    } as any))
+      .toBeUndefined()
 })
 
 test('resetEnv', () => {
@@ -264,8 +277,8 @@ suite('#getAnnotations', () => {
     expect(d).toStrictEqual(
       <CellAnnotations>{
         background: false,
-        closeTerminalOnSuccess: false,
-        interactive: false,
+        closeTerminalOnSuccess: true,
+        interactive: true,
         mimeType: 'text/plain',
         name: 'command-123',
         'runme.dev/uuid': '48d86c43-84a4-469d-8c78-963513b0f9d0'
@@ -283,8 +296,8 @@ suite('#getAnnotations', () => {
 
     expect(getAnnotations(hello)).toStrictEqual({
       background: false,
-      closeTerminalOnSuccess: false,
-      interactive: false,
+      closeTerminalOnSuccess: true,
+      interactive: true,
       mimeType: 'text/plain',
       name: 'echo-hello',
     })