Repository: stateful/vscode-runme
Pull request #42 (2022-10-31 23:55:30)

Add initialisation logging and test coverage
refs #28

As a first step to debug this issue this patch adds some logging for when initialisation for the extension fails. For that I did some minor refactoring to make code a bit more testable.

sourishkrout: @christian-bromann this branch won't run for me. looks like test fail too?
christian-bromann: Yeah, have to rebase. One sec.
christian-bromann: @sourishkrout should be ready for review now.

Patch:
diff --git a/.gitignore b/.gitignore
index fa0a9d5f..295843f1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@ node_modules
 *.vsix
 wasm
 .DS_Store
+coverage
diff --git a/__mocks__/vscode.ts b/__mocks__/vscode.ts
new file mode 100644
index 00000000..bf9c44fc
--- /dev/null
+++ b/__mocks__/vscode.ts
@@ -0,0 +1,47 @@
+import { vi } from 'vitest'
+
+export const notebooks = {
+  createNotebookController: vi.fn().mockReturnValue({
+    createNotebookCellExecution: vi.fn().mockReturnValue({ start: vi.fn(), end: vi.fn() })
+  }),
+  registerNotebookCellStatusBarItemProvider: vi.fn(),
+  createRendererMessaging: vi.fn().mockReturnValue({
+    postMessage: vi.fn(),
+    onDidReceiveMessage: vi.fn().mockReturnValue({ dispose: vi.fn() })
+  })
+}
+
+export const Uri = {
+  joinPath: vi.fn().mockReturnValue('/foo/bar'),
+  parse: vi.fn()
+}
+
+export const workspace = {
+  openTextDocument: vi.fn(),
+  registerNotebookSerializer: vi.fn(),
+  fs: {
+    readFile: vi.fn().mockResolvedValue(Buffer.from('some wasm file'))
+  }
+}
+
+export const terminal = {
+  show: vi.fn(),
+  sendText: vi.fn()
+}
+
+export const window = {
+  showWarningMessage: vi.fn(),
+  showInformationMessage: vi.fn(),
+  createTerminal: vi.fn().mockReturnValue(terminal)
+}
+
+export const commands = {
+  registerCommand: vi.fn()
+}
+
+export const env = {
+  clipboard: {
+    writeText: vi.fn()
+  },
+  openExternal: vi.fn()
+}
diff --git a/package.json b/package.json
index e427f5bc..7ac47e5f 100644
--- a/package.json
+++ b/package.json
@@ -143,7 +143,7 @@
     "postinstall": "npm run download:wasm",
     "test": "run-s test:*",
     "test:lint": "eslint src --ext ts",
-    "test:unit": "vitest",
+    "test:unit": "vitest --coverage",
     "watch": "npm run build:dev -- --watch"
   },
   "devDependencies": {
diff --git a/src/extension/commands/index.ts b/src/extension/commands/index.ts
new file mode 100644
index 00000000..b74ba793
--- /dev/null
+++ b/src/extension/commands/index.ts
@@ -0,0 +1,34 @@
+import path from 'node:path'
+
+import { NotebookCell, Uri, window, env } from 'vscode'
+
+import { CliProvider } from '../provider/cli'
+import { getTerminalByCell } from '../utils'
+
+export function openTerminal (cell: NotebookCell) {
+  const terminal = getTerminalByCell(cell)
+  if (!terminal) {
+    return window.showWarningMessage('Couldn\'t find terminal! Was it already closed?')
+  }
+  return terminal.show()
+}
+
+export function copyCellToClipboard (cell: NotebookCell) {
+  env.clipboard.writeText(cell.document.getText())
+  return window.showInformationMessage('Copied cell to clipboard!')
+}
+
+export async function runCLICommand (cell: NotebookCell) {
+  if (!await CliProvider.isCliInstalled()) {
+    return window.showInformationMessage(
+      'Runme CLI is not installed. Do you want to download it?',
+      'Download now'
+    ).then((openBrowser) => openBrowser && env.openExternal(
+      Uri.parse('https://github.com/stateful/runme/releases')
+    ))
+  }
+  const cliName: string = (cell.metadata?.['cliName'] || '').trim()
+  const term = window.createTerminal(`CLI: ${cliName}`)
+  term.show(false)
+  term.sendText(`runme run ${cliName} --chdir="${path.dirname(cell.document.uri.fsPath)}"`)
+}
diff --git a/src/extension/executors/script/utils.ts b/src/extension/executors/script/utils.ts
index 0ee4e79e..04519555 100644
--- a/src/extension/executors/script/utils.ts
+++ b/src/extension/executors/script/utils.ts
@@ -1,9 +1,12 @@
-import fs from 'node:fs'
-import path from 'node:path'
+import { workspace, Uri } from 'vscode'
 
-const tw = fs.readFileSync(path.resolve(__dirname, '..', 'assets', 'tw.min.css')).toString()
+let tw: string
 
 export function getHTMLTemplate (htmlSection: string, codeSection = '', attributes: Record<string, string>) {
+  if (!tw) {
+    tw = workspace.fs.readFile(Uri.joinPath(Uri.parse(__dirname), '..', 'assets', 'tw.min.css')).toString()
+  }
+
   return /*html*/`
   <html>
     <head>
diff --git a/src/extension/extension.ts b/src/extension/extension.ts
index 82f01897..553bb5ae 100644
--- a/src/extension/extension.ts
+++ b/src/extension/extension.ts
@@ -1,6 +1,5 @@
-import path from 'node:path'
 
-import vscode from 'vscode'
+import { workspace, notebooks, commands, ExtensionContext } from 'vscode'
 
 import { Serializer } from './notebook'
 import { Kernel } from './kernel'
@@ -8,65 +7,35 @@ import { Kernel } from './kernel'
 import { ShowTerminalProvider, BackgroundTaskProvider } from './provider/background'
 import { PidStatusProvider } from './provider/pid'
 import { CopyProvider } from './provider/copy'
-import { getTerminalByCell, resetEnv } from './utils'
+import { resetEnv } from './utils'
 import { CliProvider } from './provider/cli'
-
-// const viteProcess = new ViteServerProcess()
-
-export async function activate (context: vscode.ExtensionContext) {
-  console.log('[Runme] Activating Extension')
-  const kernel = new Kernel(context)
-
-  // await viteProcess.start()
-  context.subscriptions.push(
-    kernel,
-    // viteProcess,
-    vscode.workspace.registerNotebookSerializer('runme', new Serializer(context), {
-      transientOutputs: true,
-      transientCellMetadata: {
-        inputCollapsed: true,
-        outputCollapsed: true,
-      },
-    }),
-    vscode.notebooks.registerNotebookCellStatusBarItemProvider('runme', new ShowTerminalProvider()),
-    vscode.notebooks.registerNotebookCellStatusBarItemProvider('runme', new PidStatusProvider()),
-    vscode.notebooks.registerNotebookCellStatusBarItemProvider('runme', new CliProvider()),
-    vscode.notebooks.registerNotebookCellStatusBarItemProvider('runme', new BackgroundTaskProvider()),
-    vscode.notebooks.registerNotebookCellStatusBarItemProvider('runme', new CopyProvider()),
-    vscode.commands.registerCommand('runme.openTerminal', (cell: vscode.NotebookCell) => {
-      const terminal = getTerminalByCell(cell)
-      if (!terminal) {
-        return vscode.window.showWarningMessage('Couldn\'t find terminal! Was it already closed?')
-      }
-      return terminal.show()
-    }),
-    vscode.commands.registerCommand('runme.copyCellToClipboard', (cell: vscode.NotebookCell) => {
-      vscode.env.clipboard.writeText(cell.document.getText())
-      return vscode.window.showInformationMessage('Copied cell to clipboard!')
-    }),
-
-    vscode.commands.registerCommand('runme.runCliCommand', async (cell: vscode.NotebookCell) => {
-      if (!await CliProvider.isCliInstalled()) {
-        return vscode.window.showInformationMessage(
-          'Runme CLI is not installed. Do you want to download it?',
-          'Download now'
-        ).then((openBrowser) => openBrowser && vscode.env.openExternal(
-          vscode.Uri.parse('https://github.com/stateful/runme/releases')
-        ))
-      }
-      const cliName: string = (cell.metadata?.['cliName'] || '').trim()
-      const term = vscode.window.createTerminal(`CLI: ${cliName}`)
-      term.show(false)
-      term.sendText(`runme run ${cliName} --chdir="${path.dirname(cell.document.uri.fsPath)}"`)
-    }),
-
-    vscode.commands.registerCommand('runme.resetEnv', resetEnv)
-  )
-
-  console.log('[Runme] Extension successfully activated')
-}
-
-// This method is called when your extension is deactivated
-export function deactivate () {
-  // viteProcess.stop()
+import { openTerminal, runCLICommand, copyCellToClipboard } from './commands'
+
+export class RunmeExtension {
+  async initialise (context: ExtensionContext) {
+    const kernel = new Kernel(context)
+    // const viteProcess = new ViteServerProcess()
+    // await viteProcess.start()
+
+    context.subscriptions.push(
+      kernel,
+      // viteProcess,
+      workspace.registerNotebookSerializer('runme', new Serializer(context), {
+        transientOutputs: true,
+        transientCellMetadata: {
+          inputCollapsed: true,
+          outputCollapsed: true,
+        },
+      }),
+      notebooks.registerNotebookCellStatusBarItemProvider('runme', new ShowTerminalProvider()),
+      notebooks.registerNotebookCellStatusBarItemProvider('runme', new PidStatusProvider()),
+      notebooks.registerNotebookCellStatusBarItemProvider('runme', new CliProvider()),
+      notebooks.registerNotebookCellStatusBarItemProvider('runme', new BackgroundTaskProvider()),
+      notebooks.registerNotebookCellStatusBarItemProvider('runme', new CopyProvider()),
+      commands.registerCommand('runme.resetEnv', resetEnv),
+      commands.registerCommand('runme.openTerminal', openTerminal),
+      commands.registerCommand('runme.runCliCommand', runCLICommand),
+      commands.registerCommand('runme.copyCellToClipboard', copyCellToClipboard)
+    )
+  }
 }
diff --git a/src/extension/index.ts b/src/extension/index.ts
new file mode 100644
index 00000000..6b873bbc
--- /dev/null
+++ b/src/extension/index.ts
@@ -0,0 +1,19 @@
+import type { ExtensionContext } from 'vscode'
+
+import { RunmeExtension } from './extension'
+
+const ext = new RunmeExtension()
+
+export async function activate (context: ExtensionContext) {
+  console.log('[Runme] Activating Extension')
+  try {
+    await ext.initialise(context)
+    console.log('[Runme] Extension successfully activated')
+  } catch (err: any) {
+    console.log(`[Runme] Failed to initialise the extension ${err.message}`)
+  }
+}
+
+export function deactivate () {
+  console.log('[Runme] Deactivating Extension')
+}
diff --git a/src/extension/kernel.ts b/src/extension/kernel.ts
index f7320dff..571d4b95 100644
--- a/src/extension/kernel.ts
+++ b/src/extension/kernel.ts
@@ -1,4 +1,4 @@
-import vscode, { ExtensionContext, NotebookEditor } from 'vscode'
+import { Disposable, notebooks, window, workspace, ExtensionContext, NotebookEditor, NotebookCell } from 'vscode'
 
 import type { ClientMessage } from '../types'
 import { ClientMessages } from '../constants'
@@ -10,14 +10,14 @@ import { resetEnv, getKey } from './utils'
 
 import './wasm/wasm_exec.js'
 
-export class Kernel implements vscode.Disposable {
-  #disposables: vscode.Disposable[] = []
-  #controller = vscode.notebooks.createNotebookController(
+export class Kernel implements Disposable {
+  #disposables: Disposable[] = []
+  #controller = notebooks.createNotebookController(
     'runme',
     'runme',
     'RUNME'
   )
-  protected messaging = vscode.notebooks.createRendererMessaging('runme-renderer')
+  protected messaging = notebooks.createRendererMessaging('runme-renderer')
 
   constructor(protected context: ExtensionContext) {
     this.#controller.supportedLanguages = Object.keys(executor)
@@ -60,22 +60,22 @@ export class Kernel implements vscode.Disposable {
         return this._doExecuteCell(cell)
       }
     } else if (message.type === ClientMessages.infoMessage) {
-      return vscode.window.showInformationMessage(message.output as string)
+      return window.showInformationMessage(message.output as string)
     } else if (message.type === ClientMessages.errorMessage) {
-      return vscode.window.showInformationMessage(message.output as string)
+      return window.showInformationMessage(message.output as string)
     }
 
     console.error(`[Runme] Unknown event type: ${message.type}`)
   }
 
-  private async _executeAll(cells: vscode.NotebookCell[]) {
+  private async _executeAll(cells: NotebookCell[]) {
     for (const cell of cells) {
       await this._doExecuteCell(cell)
     }
   }
 
-  private async _doExecuteCell(cell: vscode.NotebookCell): Promise<void> {
-    const runningCell = await vscode.workspace.openTextDocument(cell.document.uri)
+  private async _doExecuteCell(cell: NotebookCell): Promise<void> {
+    const runningCell = await workspace.openTextDocument(cell.document.uri)
     const exec = this.#controller.createNotebookCellExecution(cell)
 
     exec.start(Date.now())
diff --git a/src/extension/notebook.ts b/src/extension/notebook.ts
index 02371d1f..a2cce6a5 100644
--- a/src/extension/notebook.ts
+++ b/src/extension/notebook.ts
@@ -1,7 +1,6 @@
-import fs from 'node:fs'
-// import fsp from 'node:fs/promises'
-
-import vscode from 'vscode'
+import {
+  NotebookSerializer, ExtensionContext, Uri, workspace, NotebookData, NotebookCellData, NotebookCellKind
+} from 'vscode'
 
 import type { ParsedDocument } from '../types'
 
@@ -16,27 +15,30 @@ declare var globalThis: any
 const DEFAULT_LANG_ID = 'text'
 const LANGUAGES_WITH_INDENTATION = ['html', 'tsx', 'ts', 'js']
 
-export class Serializer implements vscode.NotebookSerializer {
+export class Serializer implements NotebookSerializer {
   private fileContent?: string
   private readonly ready: Promise<Error | void>
-  private readonly languages = Languages.fromContext(this.context)
+  private readonly languages: Languages
+
+  constructor(private context: ExtensionContext) {
+    this.languages = Languages.fromContext(this.context)
+    this.ready = this.#initWasm()
+  }
 
-  constructor(private context: vscode.ExtensionContext) {
+  async #initWasm () {
     const go = new globalThis.Go()
-    const wasmUri = vscode.Uri.joinPath(this.context.extensionUri, 'wasm', 'runme.wasm')
-    this.ready = WebAssembly.instantiate(
-      fs.readFileSync(wasmUri.fsPath),
-      go.importObject
-    ).then(
+    const wasmUri = Uri.joinPath(this.context.extensionUri, 'wasm', 'runme.wasm')
+    const wasmFile = await workspace.fs.readFile(wasmUri)
+    return WebAssembly.instantiate(wasmFile, go.importObject).then(
       (result) => { go.run(result.instance) },
       (err: Error) => {
-        console.error(err)
+        console.error(`[Runme] failed initialising WASM file: ${err.message}`)
         return err
       }
     )
   }
 
-  public async deserializeNotebook(content: Uint8Array): Promise<vscode.NotebookData> {
+  public async deserializeNotebook(content: Uint8Array): Promise<NotebookData> {
     const err = await this.ready
 
     const md = content.toString()
@@ -76,8 +78,8 @@ export class Serializer implements vscode.NotebookSerializer {
        * code block description
        */
       if (s.markdown) {
-        const cell = new vscode.NotebookCellData(
-          vscode.NotebookCellKind.Markup,
+        const cell = new NotebookCellData(
+          NotebookCellKind.Markup,
           s.markdown,
           'markdown'
         )
@@ -89,8 +91,8 @@ export class Serializer implements vscode.NotebookSerializer {
       if (s.lines && isSupported) {
         const lines = s.lines.join('\n')
         const language = normalizeLanguage(s.language)
-        const cell = new vscode.NotebookCellData(
-          vscode.NotebookCellKind.Code,
+        const cell = new NotebookCellData(
+          NotebookCellKind.Code,
           /**
            * for JS content we want to keep indentation
            */
@@ -112,8 +114,8 @@ export class Serializer implements vscode.NotebookSerializer {
         acc.push(cell)
       } else if (s.language) {
         const mdContent = s.content ?? (s.lines || []).join('\n').trim()
-        const cell = new vscode.NotebookCellData(
-          vscode.NotebookCellKind.Markup,
+        const cell = new NotebookCellData(
+          NotebookCellKind.Markup,
           `\`\`\`${s.language}\n${mdContent}\n\`\`\``,
           'markdown'
         )
@@ -121,13 +123,13 @@ export class Serializer implements vscode.NotebookSerializer {
         acc.push(cell)
       }
       return acc
-    }, [] as vscode.NotebookCellData[])
-    return new vscode.NotebookData(cells)
+    }, [] as NotebookCellData[])
+    return new NotebookData(cells)
   }
 
   public async serializeNotebook(
-    // data: vscode.NotebookData,
-    // token: vscode.CancellationToken
+    // data: NotebookData,
+    // token: CancellationToken
   ): Promise<Uint8Array> {
     // eslint-disable-next-line max-len
     throw new Error('Notebooks are currently read-only. Please edit markdown in file-mode (right click: "Open With...") instead.')
@@ -135,7 +137,7 @@ export class Serializer implements vscode.NotebookSerializer {
     // Below's impl is highly experimental and will leads unpredictable results
     // including data loss
     //
-    // const markdownFile = vscode.window.activeTextEditor?.document.fileName
+    // const markdownFile = window.activeTextEditor?.document.fileName
     // if (!markdownFile) {
     //   throw new Error('Could not detect opened markdown document')
     // }
@@ -174,8 +176,8 @@ export class Serializer implements vscode.NotebookSerializer {
   }
 
   #printCell (content: string, languageId = 'markdown') {
-    return new vscode.NotebookData([
-      new vscode.NotebookCellData(vscode.NotebookCellKind.Markup, content, languageId)
+    return new NotebookData([
+      new NotebookCellData(NotebookCellKind.Markup, content, languageId)
     ])
   }
 }
diff --git a/webpack.config.ts b/webpack.config.ts
index f9162690..1984015a 100644
--- a/webpack.config.ts
+++ b/webpack.config.ts
@@ -104,7 +104,7 @@ const extensionConfig: Configuration = {
   ...baseConfig,
   target: 'node',
   entry: {
-    extension: path.resolve(__dirname, 'src', 'extension', 'extension.ts'),
+    extension: path.resolve(__dirname, 'src', 'extension', 'index.ts'),
   },
   externals: ['vscode', 'vercel', '@vercel/client', 'keyv'],
   output: {


Test patch:
diff --git a/tests/extension/commands/index.test.ts b/tests/extension/commands/index.test.ts
new file mode 100644
index 00000000..1b9d3e05
--- /dev/null
+++ b/tests/extension/commands/index.test.ts
@@ -0,0 +1,62 @@
+import { beforeEach, expect, test, vi } from 'vitest'
+import {
+  window, env,
+  // @ts-expect-error mock feature
+  terminal
+} from 'vscode'
+
+import { openTerminal, copyCellToClipboard, runCLICommand } from '../../../src/extension/commands'
+import { getTerminalByCell } from '../../../src/extension/utils'
+import { CliProvider } from '../../../src/extension/provider/cli'
+
+vi.mock('vscode')
+vi.mock('../../../src/extension/utils', () => ({
+  getTerminalByCell: vi.fn()
+}))
+vi.mock('../../../src/extension/provider/cli', () => ({
+  CliProvider: {
+    isCliInstalled: vi.fn()
+  }
+}))
+
+beforeEach(() => {
+  vi.mocked(window.showWarningMessage).mockClear()
+  vi.mocked(window.showInformationMessage).mockClear()
+})
+
+test('openTerminal', () => {
+  expect(openTerminal({} as any)).toBe(undefined)
+  expect(window.showWarningMessage).toBeCalledTimes(1)
+
+  vi.mocked(getTerminalByCell).mockReturnValue({ show: vi.fn().mockReturnValue('showed') } as any)
+  expect(openTerminal({} as any)).toBe('showed')
+})
+
+test('copyCellToClipboard', () => {
+  const cell: any = { document: { getText: vi.fn().mockReturnValue('foobar') } }
+  copyCellToClipboard(cell)
+  expect(env.clipboard.writeText).toBeCalledWith('foobar')
+  expect(window.showInformationMessage).toBeCalledTimes(1)
+})
+
+test('runCLICommand if CLI is not installed', async () => {
+  const cell: any = {}
+  vi.mocked(window.showInformationMessage).mockResolvedValueOnce(false as any)
+  await runCLICommand(cell)
+  expect(env.openExternal).toBeCalledTimes(0)
+  vi.mocked(window.showInformationMessage).mockResolvedValue(true as any)
+  await runCLICommand(cell)
+  expect(env.openExternal).toBeCalledTimes(1)
+})
+
+test('runCLICommand if CLI is installed', async () => {
+  const cell: any = {
+    metadata: { cliName: 'foobar' },
+    document: { uri: { fsPath: '/foo/bar' }}
+  }
+  vi.mocked(CliProvider.isCliInstalled).mockResolvedValue(true)
+  await runCLICommand(cell)
+  expect(window.createTerminal).toBeCalledWith('CLI: foobar')
+  expect(terminal.show).toBeCalledTimes(1)
+  expect(terminal.sendText).toBeCalledWith('runme run foobar --chdir="/foo"')
+})
diff --git a/tests/extension/extension.test.ts b/tests/extension/extension.test.ts
new file mode 100644
index 00000000..29e4116b
--- /dev/null
+++ b/tests/extension/extension.test.ts
@@ -0,0 +1,15 @@
+import { test, expect, vi } from 'vitest'
+import { notebooks, workspace, commands } from 'vscode'
+
+import { RunmeExtension } from '../../src/extension/extension'
+
+vi.mock('vscode')
+
+test('initialises all providers', async () => {
+  const context: any = { subscriptions: [], extensionUri: { fsPath: '/foo/bar' } }
+  const ext = new RunmeExtension()
+  await ext.initialise(context)
+  expect(notebooks.registerNotebookCellStatusBarItemProvider).toBeCalledTimes(5)
+  expect(workspace.registerNotebookSerializer).toBeCalledTimes(1)
+  expect(commands.registerCommand).toBeCalledTimes(4)
+})
diff --git a/tests/extension/kernel.test.ts b/tests/extension/kernel.test.ts
index e981c4f1..49d36b2a 100644
--- a/tests/extension/kernel.test.ts
+++ b/tests/extension/kernel.test.ts
@@ -4,31 +4,16 @@ import { Kernel } from '../../src/extension/kernel'
 import { resetEnv } from '../../src/extension/utils'
 import executors from '../../src/extension/executors'
 
+
+vi.mock('vscode')
 vi.mock('../../src/extension/utils', () => ({
   resetEnv: vi.fn(),
   getKey: vi.fn().mockReturnValue('foobar')
 }))
-
 vi.mock('../../src/extension/executors/index.js', () => ({
   default: { foobar: vi.fn() }
 }))
 
-vi.mock('vscode', () => ({
-  default: {
-    notebooks: {
-      createRendererMessaging: vi.fn().mockReturnValue({
-        postMessage: vi.fn(),
-        onDidReceiveMessage: vi.fn().mockReturnValue({ dispose: vi.fn() })
-      }),
-      createNotebookController: vi.fn().mockReturnValue({
-        createNotebookCellExecution: vi.fn().mockReturnValue({ start: vi.fn(), end: vi.fn() })
-      })
-    },
-    workspace: {
-      openTextDocument: vi.fn()
-    }
-  }
-}))
 
 test('dispose', () => {
   const k = new Kernel({} as any)
diff --git a/vitest.conf.ts b/vitest.conf.ts
index c1016ff0..e6fff107 100644
--- a/vitest.conf.ts
+++ b/vitest.conf.ts
@@ -12,12 +12,12 @@ export default defineConfig({
       '**/node_modules/**'
     ],
     coverage: {
-      enabled: false,
+      enabled: true,
       exclude: ['**/build/**', '**/__fixtures__/**', '**/*.test.ts'],
-      lines: 100,
-      functions: 100,
-      branches: 100,
-      statements: 100
+      statements: 38,
+      branches: 90,
+      functions: 33,
+      lines: 38
     }
   }
 })