diff --git a/.gitignore b/.gitignore index fa0a9d5f9..295843f1a 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 000000000..bf9c44fcb --- /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 e427f5bc3..7ac47e5f1 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 000000000..b74ba7933 --- /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 0ee4e79e4..045195552 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) { + if (!tw) { + tw = workspace.fs.readFile(Uri.joinPath(Uri.parse(__dirname), '..', 'assets', 'tw.min.css')).toString() + } + return /*html*/` diff --git a/src/extension/extension.ts b/src/extension/extension.ts index 82f018972..553bb5aec 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 000000000..6b873bbc1 --- /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 f7320dffb..571d4b95e 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 { - const runningCell = await vscode.workspace.openTextDocument(cell.document.uri) + private async _doExecuteCell(cell: NotebookCell): Promise { + 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 02371d1f2..a2cce6a56 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 - 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 { + public async deserializeNotebook(content: Uint8Array): Promise { 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 { // 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/tests/extension/commands/index.test.ts b/tests/extension/commands/index.test.ts new file mode 100644 index 000000000..1b9d3e05b --- /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 000000000..29e4116bc --- /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 e981c4f18..49d36b2a1 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 c1016ff0e..e6fff107d 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 } } }) diff --git a/webpack.config.ts b/webpack.config.ts index f9162690b..1984015a5 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: {