Repository: stateful/vscode-runme
Pull request #159 (2023-01-27 02:10:28)

Open in Runme
This patch adds a uri handler that would allow us to have a link like `vscode://stateful.runme?command=setup&repository=git@github.com:webdriverio/webdriverio.git` anywhere to have users check out a project locally with Runme. An initial POC looks like this:

![boot](https://user-images.githubusercontent.com/731337/214995343-2d6378fd-65f7-42af-b32c-24d4c390cb8f.gif)

If users don't have Runme installed the following message will be displayed and allows users to immediately get started:

<img width="322" alt="Screenshot 2023-01-31 at 13 17 10" src="https://user-images.githubusercontent.com/731337/215848216-c807b62c-8fde-45b8-be33-a8270f54647c.png">


sourishkrout: Btw, I fixed the URI in the description, it's `vscode://stateful.runme?command=setup&repository=git@github.com:webdriverio/webdriverio.git` versus `vscode://stateful.runme?command=setup&repository=git@github.com/webdriverio/webdriverio.git`.
christian-bromann: > unless I'm doing something wrong?

The current implementation only accepts git urls of the format `git@provider.com:org/project.git` as a start. This can be extended arbitrarily. The [`getSuggestedProjectName`](https://github.com/stateful/vscode-runme/pull/159/files#diff-6834805a1e704915e395b33be32e6b4b214a09a2904f5d2f3eda9134bd1c89dcR122-R139) is responsible for parsing this information.

> In my mind the .runme file semantics are something to mull over and address.

Sure. This can be changed later on without having to worry about UX regressions given that the file is temporary and will not be part of the project. I currently don't have enough information about future use cases that I can make any better guesses about this.
sourishkrout: > The current implementation only accepts git urls of the format `git@provider.com:org/project.git` as a start. This can be extended arbitrarily. The [`getSuggestedProjectName`](https://github.com/stateful/vscode-runme/pull/159/files#diff-6834805a1e704915e395b33be32e6b4b214a09a2904f5d2f3eda9134bd1c89dcR122-R139) is responsible for parsing this information.

OK. Let's support it before merging please.

> Sure. This can be changed later on without having to worry about UX regressions given that the file is temporary and will not be part of the project. I currently don't have enough information about future use cases that I can make any better guesses about this.

Makes sense. What I'm saying is that `.runme` it ever exists at the root of a repo should be a directory not a file. Not asking for guessing, just to not use it as a file please.
christian-bromann: > if it ever exists at the root of a repo should be a directory not a file.

Can you help me understand the reasoning? For me it didn't matter much and I decided for the simplest solution which is a basic file. Using a directory would imply we use it for other types of files too which we currently don't, also the file contains the information about which file of the project runme should open. It also didn't really matter that much because the file is removed once VS Code opened.

Anyway, thinking about this a bit more made me realise that this information can be stored in the extension directory which would allow to not create it in the first place, so I will go with that.
sourishkrout: > Can you help me understand the reasoning? For me it didn't matter much and I decided for the simplest solution which is a basic file. Using a directory would imply we use it for other types of files too which we currently don't, also the file contains the information about which file of the project runme should open. It also didn't really matter that much because the file is removed once VS Code opened.
> 
> Anyway, thinking about this a bit more made me realise that this information can be stored in the extension directory which would allow to not create it in the first place, so I will go with that.

I'm ok with `.runme_bootstrap` or `.runme/bootstrap`. not really opinionated on `bootstrap`. it's more about being specific and consistency to have all `.runme` info in a common dir and being straightforward how that will work.
christian-bromann: @sourishkrout @mxsdev can I have another round of feedback?
sourishkrout: LGTM 👍 .

Patch:
diff --git a/__mocks__/vscode.ts b/__mocks__/vscode.ts
index b3db1976..b9f73389 100644
--- a/__mocks__/vscode.ts
+++ b/__mocks__/vscode.ts
@@ -32,7 +32,9 @@ export const workspace = {
   onDidSaveNotebookDocument: vi.fn().mockReturnValue({ dispose: vi.fn() }),
   fs: {
     readFile: vi.fn().mockResolvedValue(Buffer.from('some wasm file')),
-    stat: vi.fn().mockResolvedValue(1)
+    stat: vi.fn().mockResolvedValue(1),
+    createDirectory: vi.fn(),
+    delete: vi.fn()
   },
   workspaceFolders: [
     {
@@ -59,18 +61,22 @@ export const workspace = {
 
 export const terminal = {
   show: vi.fn(),
-  sendText: vi.fn()
+  sendText: vi.fn(),
+  dispose: vi.fn()
 }
 
 export const window = {
   showWarningMessage: vi.fn(),
   showInformationMessage: vi.fn(),
+  showErrorMessage: vi.fn(),
   createTerminal: vi.fn().mockReturnValue(terminal),
   showNotebookDocument: vi.fn(),
   showTextDocument: vi.fn(),
   onDidChangeActiveNotebookEditor: vi.fn().mockReturnValue({ dispose: vi.fn() }),
   registerTreeDataProvider: vi.fn(),
-  onDidCloseTerminal: vi.fn()
+  registerUriHandler: vi.fn(),
+  onDidCloseTerminal: vi.fn(),
+  withProgress: vi.fn()
 }
 
 export const tasks = {
@@ -174,3 +180,7 @@ export class NotebookCellOutput {
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   constructor(items: NotebookCellOutputItem[], metadata?: { [key: string]: any }) {}
 }
+
+export const ProgressLocation = {
+  Window: 1
+}
diff --git a/package.json b/package.json
index 81a8e087..c3feb542 100644
--- a/package.json
+++ b/package.json
@@ -240,6 +240,17 @@
             "scope": "machine",
             "default": false,
             "markdownDescription": "If set to `true`, the notebook serialization will use the GRPC interface (experimental)."
+          },
+          "runme.checkout.projectDir": {
+            "type": "string",
+            "scope": "machine",
+            "markdownDescription": "Directory to which Runme should clone new projects into."
+          },
+          "runme.checkout.timeout": {
+            "type": "number",
+            "scope": "machine",
+            "default": 60000,
+            "markdownDescription": "Timeout until Runme will cancel the clone process."
           }
         }
       }
@@ -322,7 +333,7 @@
     "build": "run-s build:dev",
     "build:dev": "webpack --mode development --node-env development",
     "build:prod": "webpack --mode production --node-env production",
-    "bundle": "vsce package --pre-release --npm --no-git-tag-version --no-update-package-json -o \"./runme-$npm_package_version.vsix\"",
+    "bundle": "vsce package --pre-release --no-git-tag-version --no-update-package-json -o \"./runme-$npm_package_version.vsix\"",
     "download:wasm": "node .github/scripts/downloadWasm.js",
     "esmify": "echo '{ \"type\": \"module\" }' >> ./out/package.json",
     "postinstall": "run-s postinstall:*",
diff --git a/src/extension/constants.ts b/src/extension/constants.ts
index ecb4333d..ea57e75a 100644
--- a/src/extension/constants.ts
+++ b/src/extension/constants.ts
@@ -11,3 +11,5 @@ export const DEFAULT_ENV = {
 export const ENV_STORE = new Map<string, string>(
   Object.entries(DEFAULT_ENV)
 )
+
+export const BOOTFILE = '.runme_bootstrap'
diff --git a/src/extension/extension.ts b/src/extension/extension.ts
index 292945aa..18bb20f1 100644
--- a/src/extension/extension.ts
+++ b/src/extension/extension.ts
@@ -1,5 +1,5 @@
 
-import { workspace, notebooks, commands, ExtensionContext, tasks, window } from 'vscode'
+import { workspace, notebooks, commands, ExtensionContext, tasks, window, Uri } from 'vscode'
 import { TelemetryReporter } from 'vscode-telemetry'
 
 import { Kernel } from './kernel'
@@ -20,6 +20,8 @@ import {
 } from './commands'
 import { WasmSerializer, GrpcSerializer } from './serializer'
 import { RunmeLauncherProvider } from './provider/launcher'
+import { RunmeUriHandler } from './handler/uri'
+import { BOOTFILE } from './constants'
 
 export class RunmeExtension {
   async initialize(context: ExtensionContext) {
@@ -27,6 +29,7 @@ export class RunmeExtension {
     const grpcSerializer = kernel.hasExperimentEnabled('grpcSerializer')
     const serializer = grpcSerializer ? new GrpcSerializer(context) : new WasmSerializer(context)
     const treeViewer = new RunmeLauncherProvider(getDefaultWorkspace())
+    const uriHandler = new RunmeUriHandler()
 
     context.subscriptions.push(
       kernel,
@@ -60,7 +63,12 @@ export class RunmeExtension {
        */
       window.registerTreeDataProvider('runme.launcher', treeViewer),
       commands.registerCommand('runme.collapseTreeView', treeViewer.collapseAll.bind(treeViewer)),
-      commands.registerCommand('runme.expandTreeView', treeViewer.expandAll.bind(treeViewer))
+      commands.registerCommand('runme.expandTreeView', treeViewer.expandAll.bind(treeViewer)),
+
+      /**
+       * Uri handler
+       */
+      window.registerUriHandler(uriHandler)
     )
 
     /**
@@ -70,6 +78,17 @@ export class RunmeExtension {
     !hasPsuedoTerminalExperimentEnabled
       ? context.subscriptions.push(notebooks.registerNotebookCellStatusBarItemProvider(Kernel.type, new CliProvider()))
       : tasks.registerTaskProvider(RunmeTaskProvider.id, new RunmeTaskProvider(context))
+
+    if (workspace.workspaceFolders?.length && workspace.workspaceFolders[0]) {
+      const startupFileUri = Uri.joinPath(workspace.workspaceFolders[0].uri, BOOTFILE)
+      const hasStartupFile = await workspace.fs.stat(startupFileUri).then(() => true, () => false)
+      if (hasStartupFile) {
+        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)
+      }
+    }
   }
 
   static registerCommand(command: string, callback: (...args: any[]) => any, thisArg?: any) {
diff --git a/src/extension/handler/uri.ts b/src/extension/handler/uri.ts
new file mode 100644
index 00000000..565246c5
--- /dev/null
+++ b/src/extension/handler/uri.ts
@@ -0,0 +1,80 @@
+import { UriHandler, window, Uri, Progress, ProgressLocation, commands } from 'vscode'
+
+import { getProjectDir, getTargetDirName, waitForProjectCheckout, getSuggestedProjectName } from './utils'
+
+let NEXT_TERM_ID = 0
+const DEFAULT_START_FILE = 'README.md'
+
+export class RunmeUriHandler implements UriHandler {
+    async handleUri (uri: Uri) {
+        console.log(`[Runme] triggered RunmeUriHandler with ${uri}`)
+        const params = new URLSearchParams(uri.query)
+        const command = params.get('command')
+
+        if (!command) {
+            window.showErrorMessage('No query parameter "command" provided')
+            return
+        }
+
+        if (command === 'setup') {
+            const fileToOpen = params.get('fileToOpen') || DEFAULT_START_FILE
+            await this._setupProject(fileToOpen, params.get('repository'))
+            return
+        }
+
+        window.showErrorMessage(`Couldn't recognise command "${command}"`)
+    }
+
+    private async _setupProject (fileToOpen: string, repository?: string | null) {
+        if (!repository) {
+            return window.showErrorMessage('No project to setup was provided in the url')
+        }
+
+        const suggestedProjectName = getSuggestedProjectName(repository)
+        const projectDirUri = await getProjectDir()
+
+        /**
+         * cancel operation if
+         * - user doesn't want to create set up project directory
+         * - we aren't able to parse the suggested name due to invalid repository param format
+         */
+        if (!projectDirUri || !suggestedProjectName) {
+            return
+        }
+
+        const targetDirUri = Uri.joinPath(projectDirUri, await getTargetDirName(projectDirUri, suggestedProjectName))
+        window.showInformationMessage('Setting up a new project using Runme...')
+        return window.withProgress({
+            location: ProgressLocation.Window,
+            cancellable: false,
+            title: `Setting up project from repository ${repository}`
+        }, (progress) => this._cloneProject(progress, targetDirUri, repository, fileToOpen))
+    }
+
+    private async _cloneProject (
+        progress: Progress<{ message?: string, increment?: number }>,
+        targetDirUri: Uri,
+        repository: string,
+        fileToOpen: string
+    ) {
+        progress.report({ increment: 0, message: 'Cloning repository...' })
+        const terminal = window.createTerminal(`Runme Terminal #${NEXT_TERM_ID++}`, '/bin/sh')
+        terminal.show(true)
+
+        terminal.sendText(`git clone ${repository} ${targetDirUri.fsPath}`)
+        const success = await new Promise<boolean>(
+            (resolve) => waitForProjectCheckout(fileToOpen, targetDirUri.fsPath, repository, resolve))
+
+        if (!success) {
+            return terminal.dispose()
+        }
+
+        progress.report({ increment: 50, message: 'Opening project...' })
+        console.log(`[Runme] Attempt to open folder ${targetDirUri.fsPath}`)
+        await commands.executeCommand('vscode.openFolder', Uri.parse(targetDirUri.fsPath), {
+            forceNewWindow: true
+        })
+        progress.report({ increment: 100 })
+        terminal.dispose()
+    }
+}
diff --git a/src/extension/handler/utils.ts b/src/extension/handler/utils.ts
new file mode 100644
index 00000000..76e23cbb
--- /dev/null
+++ b/src/extension/handler/utils.ts
@@ -0,0 +1,157 @@
+import { workspace, window, Uri } from 'vscode'
+import { dir } from 'tmp-promise'
+
+import { BOOTFILE } from '../constants'
+
+const config = workspace.getConfiguration('runme.checkout')
+const INTERVAL = 500
+const MINIMAL_TIMEOUT = 10 * 1000
+
+/**
+ * Get the project directory from the settings object.
+ *
+ * If none is set up, the directory will be a temporary directory.
+ *
+ * If the set up directory doesn't exist, it will ask whether to create it and if not
+ * we return `null` meaning we should cancel the checkout operation.
+ *
+ * @returns `vscode.Uri` with target directory to check out the project to
+ *          `null` if the user doesn't want to create the directory, here we should cancel the operation
+ */
+export async function getProjectDir () {
+    const projectDirPath: string | undefined = config.get('projectDir')
+
+    if (!projectDirPath) {
+        return Uri.parse((await dir()).path)
+    }
+
+    const projectDir = Uri.parse(projectDirPath)
+    const isExisting = await workspace.fs.stat(projectDir)
+        .then(() => true, () => false)
+    if (isExisting) {
+        return projectDir
+    }
+
+    const createDir = (await window.showInformationMessage(
+        `A project directory (${projectDir}) was set up but doesn't exist. ` +
+        'Do you want to create it?',
+        'Yes', 'No'
+    )) === 'Yes'
+
+    if (!createDir) {
+        return null
+    }
+
+    await workspace.fs.createDirectory(projectDir)
+    return projectDir
+}
+
+/**
+ * Create the name of the target directory to checkout a project to
+ * @param targetDir Uri of the base directory (received by calling `getProjectDir`)
+ * @param suggestedName name of the directory to check the project into, e.g. "org/projectName"
+ * @param index index which increases if directory name exists (e.g. "foobar_1")
+ * @returns a string with the name of the target directory
+ */
+export async function getTargetDirName (targetDir: Uri, suggestedName: string, index = 0): Promise<string> {
+    /**
+     * for now let's expect a suggested name mimicking the format "<org>/<project>"
+     */
+    if (suggestedName.split('/').length !== 2) {
+        throw new Error(`Invalid project directory suggestion: ${suggestedName}`)
+    }
+
+    /**
+     * create org directory
+     */
+    const [orgName] = suggestedName.split('/')
+    const orgDir = Uri.joinPath(targetDir, orgName)
+    const isOrgDirExisting = await workspace.fs.stat(orgDir).then(() => true, () => false)
+    if (!isOrgDirExisting) {
+        await workspace.fs.createDirectory(orgDir)
+    }
+
+    const amendedSuggestedName = !index ? suggestedName: `${suggestedName}_${index}`
+    const fullTargetDir = Uri.joinPath(targetDir, amendedSuggestedName)
+    const isExisting = await workspace.fs.stat(fullTargetDir).then(() => true, () => false)
+    if (isExisting) {
+        return getTargetDirName(targetDir, suggestedName, ++index)
+    }
+
+    return amendedSuggestedName
+}
+
+export async function waitForProjectCheckout (
+    fileToOpen: string,
+    targetDir: string,
+    repository: string,
+    cb: (success: boolean) => void
+) {
+    const targetDirUri = Uri.parse(targetDir)
+    const t = setTimeout(() => {
+        clearInterval(i)
+        window.showErrorMessage(`Timed out cloning repository ${repository}`)
+        return cb(false)
+    }, Math.max(config.get('timeout') || 0, MINIMAL_TIMEOUT))
+    const i = setInterval(async () => {
+        const dirEntries = await workspace.fs.readDirectory(targetDirUri).then((res) => res, (err) => {
+            console.error(`[Runme] Failed to fetch directory ${targetDir}: ${err.message}`)
+            return []
+        })
+
+        /**
+         * wait until directory has more files than only a ".git"
+         */
+        if (dirEntries.length <= 1) {
+            return
+        }
+
+        clearTimeout(t)
+        clearInterval(i)
+        console.log(`[Runme] Successfully cloned repository to ${targetDir}`)
+
+        /**
+         * write a runme file into the directory, so the extension knows it has
+         * to open the readme file
+         */
+        const fileExist = await workspace.fs.stat(Uri.joinPath(targetDirUri, fileToOpen))
+            .then(() => true, () => false)
+        if (fileExist) {
+            const enc = new TextEncoder()
+            await workspace.fs.writeFile(
+                Uri.joinPath(targetDirUri, BOOTFILE),
+                enc.encode(fileToOpen)
+            )
+            console.log(`[Runme] Created temporary bootstrap file to open ${fileToOpen}`)
+        }
+
+        cb(true)
+    }, INTERVAL)
+}
+
+/**
+ * verify repository url has the right format and get suggested name from provided repository url
+ */
+const DOT_GIT_ANNEX = '.git'
+const DOT_GIT_ANNEX_LENGTH = DOT_GIT_ANNEX.length
+export function getSuggestedProjectName (repository: string) {
+    /**
+     * for "git@provider.com:org/project.git"
+     */
+    if (repository.startsWith('git@') && repository.endsWith(DOT_GIT_ANNEX) && repository.split(':').length === 2) {
+        return repository.slice(0, -DOT_GIT_ANNEX_LENGTH).split(':')[1]
+    }
+
+    /**
+     * for "https://provider.com/org/project.git"
+     */
+    if (repository.startsWith('http') && repository.endsWith(DOT_GIT_ANNEX)) {
+        return repository.split('/').slice(-2).join('/').slice(0, -DOT_GIT_ANNEX_LENGTH)
+    }
+
+    window.showErrorMessage(
+        'Invalid git url, expected following format "git@provider.com:org/project.git",' +
+        ` received "${repository}"`
+    )
+    return
+}


Test patch:
diff --git a/tests/extension/extension.test.ts b/tests/extension/extension.test.ts
index 32cf2ac8..79d1f487 100644
--- a/tests/extension/extension.test.ts
+++ b/tests/extension/extension.test.ts
@@ -18,4 +18,9 @@ test('initializes all providers', async () => {
   expect(workspace.registerNotebookSerializer).toBeCalledTimes(1)
   expect(commands.registerCommand).toBeCalledTimes(12)
   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')
 })
diff --git a/tests/extension/handler/uri.test.ts b/tests/extension/handler/uri.test.ts
new file mode 100644
index 00000000..d1ef4978
--- /dev/null
+++ b/tests/extension/handler/uri.test.ts
@@ -0,0 +1,121 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+// @ts-expect-error mock feature
+import { commands, window, Uri, terminal } from 'vscode'
+
+import { RunmeUriHandler } from '../../../src/extension/handler/uri'
+import {
+    getProjectDir, waitForProjectCheckout, getSuggestedProjectName
+} from '../../../src/extension/handler/utils'
+
+vi.mock('vscode')
+vi.mock('vscode-telemetry')
+vi.mock('../../../src/extension/handler/utils', () => ({
+    getProjectDir: vi.fn(),
+    getTargetDirName: vi.fn(),
+    waitForProjectCheckout: vi.fn(),
+    getSuggestedProjectName: vi.fn()
+}))
+
+describe('RunmeUriHandler', () => {
+    beforeEach(() => {
+        vi.mocked(window.showErrorMessage).mockClear()
+        vi.mocked(window.showInformationMessage).mockClear()
+        vi.mocked(commands.executeCommand).mockClear()
+        vi.mocked(Uri.parse).mockReset()
+    })
+
+    describe('handleUri', async () => {
+        let handler: RunmeUriHandler
+
+        beforeEach(() => {
+            handler = new RunmeUriHandler()
+            handler['_setupProject'] = vi.fn()
+        })
+
+        it('should fail if no command was found', async () => {
+            vi.mocked(Uri.parse).mockReturnValue({ query: {} } as any)
+            expect(await handler.handleUri(Uri.parse('vscode://stateful.runme?foo=bar')))
+                .toBe(undefined)
+            expect(window.showErrorMessage).toBeCalledWith('No query parameter "command" provided')
+        })
+
+        it('should fail if no command was recognised', async () => {
+            vi.mocked(Uri.parse).mockReturnValue({ query: { command: 'foobar' } } as any)
+            expect(await handler.handleUri(Uri.parse('vscode://stateful.runme?foo=bar')))
+                .toBe(undefined)
+            expect(window.showErrorMessage).toBeCalledWith('Couldn\'t recognise command "foobar"')
+        })
+
+        it('runs _setupProject if command was "setup"', async () => {
+            vi.mocked(Uri.parse).mockReturnValue({ query: {
+                command: 'setup',
+                fileToOpen: '/sub/file.md',
+                repository: 'git@github.com:/foo/bar'
+            }} as any)
+            await handler.handleUri(Uri.parse('vscode://stateful.runme?foo=bar'))
+            expect(handler['_setupProject']).toBeCalledWith('/sub/file.md', 'git@github.com:/foo/bar')
+        })
+    })
+
+    describe('_setupProject', () => {
+        let handler: RunmeUriHandler
+
+        beforeEach(() => {
+            handler = new RunmeUriHandler()
+        })
+
+        it('doesn not do anything if repository was not provided', async () => {
+            await handler['_setupProject']('README.md')
+            expect(window.showErrorMessage)
+                .toBeCalledWith('No project to setup was provided in the url')
+            expect(window.showInformationMessage).toHaveBeenCalledTimes(0)
+        })
+
+        it('should not run if project dir or suggested name can not be identified', async () => {
+            await handler['_setupProject']('README.md', 'foo')
+            expect(window.showInformationMessage).toHaveBeenCalledTimes(0)
+            vi.mocked(getProjectDir).mockResolvedValueOnce('foobar' as any)
+            await handler['_setupProject']('README.md', 'foo')
+            expect(window.showInformationMessage).toHaveBeenCalledTimes(0)
+            vi.mocked(getProjectDir).mockResolvedValueOnce('foobar' as any)
+            vi.mocked(getSuggestedProjectName).mockResolvedValueOnce('stateful/runme')
+            await handler['_setupProject']('README.md', 'foo')
+            expect(window.showInformationMessage).toHaveBeenCalledTimes(1)
+            expect(window.withProgress).toHaveBeenCalledTimes(1)
+        })
+    })
+
+    describe('_cloneProject', () => {
+        let handler: RunmeUriHandler
+        const progress = { report: vi.fn() }
+
+        beforeEach(() => {
+            handler = new RunmeUriHandler()
+            progress.report.mockClear()
+            terminal.dispose.mockClear()
+        })
+
+        it('should return false if waitForProjectCheckout fails', async () => {
+            vi.mocked(waitForProjectCheckout).mockImplementation(
+                async (_, __, ___, resolve) => resolve(false))
+            await handler['_cloneProject'](progress, { fsPath: '/bar/foo' } as any, '/foo/bar', 'README.md')
+            expect(terminal.sendText).toBeCalledWith('git clone /foo/bar /bar/foo')
+            expect(terminal.dispose).toBeCalledTimes(1)
+            expect(progress.report).toBeCalledTimes(1)
+        })
+
+        it('should finish clone process', async () => {
+            vi.mocked(Uri.parse).mockReturnValue('some url' as any)
+            vi.mocked(waitForProjectCheckout).mockImplementation(
+                async (_, __, ___, resolve) => resolve(true))
+            await handler['_cloneProject'](progress, { fsPath: '/bar/foo' } as any, '/foo/bar', 'README.md')
+            expect(progress.report).toBeCalledWith({ increment: 100 })
+            expect(terminal.dispose).toBeCalledTimes(1)
+            expect(commands.executeCommand).toBeCalledWith(
+                'vscode.openFolder',
+                'some url',
+                { forceNewWindow: true }
+            )
+        })
+    })
+})
diff --git a/tests/extension/handler/utils.test.ts b/tests/extension/handler/utils.test.ts
new file mode 100644
index 00000000..01b5d74d
--- /dev/null
+++ b/tests/extension/handler/utils.test.ts
@@ -0,0 +1,89 @@
+import path from 'node:path'
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { Uri, workspace, window } from 'vscode'
+
+import {
+    getProjectDir, getTargetDirName, getSuggestedProjectName
+} from '../../../src/extension/handler/utils'
+
+vi.mocked(Uri.parse).mockImplementation((input: any) => input)
+vi.mocked(Uri.joinPath).mockImplementation(
+    (base: any, ...input: string[]) => path.join('/some/path', ...input) as any)
+vi.mock('vscode')
+vi.mock('tmp-promise', () => ({
+    dir: vi.fn().mockResolvedValue({ path: '/tmp/dir' })
+}))
+
+const config = workspace.getConfiguration()
+
+beforeEach(() => {
+    vi.mocked(workspace.fs.createDirectory).mockClear()
+})
+
+describe('getProjectDir', () => {
+    it('should use a tmp dir if config is not set', async () => {
+        expect(await getProjectDir()).toBe('/tmp/dir')
+    })
+
+    it('should return project dir if existing', async () => {
+        config.set('projectDir', 'foobar')
+        vi.mocked(Uri.parse).mockReturnValue('foobar' as any)
+        vi.mocked(workspace.fs.stat).mockResolvedValueOnce({} as any)
+        expect(await getProjectDir()).toBe('foobar')
+    })
+
+    it('should return null if user does not want to create new project dir', async () => {
+        config.set('projectDir', 'foobar')
+        vi.mocked(window.showInformationMessage).mockResolvedValue('No' as any)
+        vi.mocked(workspace.fs.stat).mockRejectedValueOnce(new Error(''))
+        expect(await getProjectDir()).toBe(null)
+    })
+
+    it('should create directory if approved', async () => {
+        config.set('projectDir', 'foobar')
+        vi.mocked(window.showInformationMessage).mockResolvedValue('Yes' as any)
+        vi.mocked(workspace.fs.stat).mockRejectedValueOnce(new Error(''))
+        expect(await getProjectDir()).toBe('foobar')
+        expect(workspace.fs.createDirectory).toBeCalledTimes(1)
+    })
+})
+
+describe('getTargetDirName', () => {
+    it('should throw if provided name does not match expected format', async () => {
+        await expect(() => getTargetDirName({} as any, 'foobar')).rejects
+            .toThrow('Invalid project directory suggestion: foobar')
+    })
+
+    it('creates org dir if needed and returns right target dir', async () => {
+        vi.mocked(workspace.fs.stat).mockImplementation(async (param: any) => {
+            if (param === '/some/path/stateful') {
+                throw new Error('ups')
+            }
+            if (param === '/some/path/stateful/runme_10') {
+                throw new Error('ups')
+            }
+            return {} as any
+        })
+        expect(await getTargetDirName({} as any, 'stateful/runme'))
+            .toBe('stateful/runme_10')
+        expect(workspace.fs.createDirectory).toBeCalledWith('/some/path/stateful')
+    })
+})
+
+describe('getSuggestedProjectName', () => {
+    it('should parse name correctly', () => {
+        expect(getSuggestedProjectName('git@provider.com:org/project.git'))
+            .toBe('org/project')
+    })
+
+    it('should parse name correctly', () => {
+        expect(getSuggestedProjectName('https://provider.com/org/project.git'))
+            .toBe('org/project')
+    })
+
+    it('should fail if format is not correct', () => {
+        expect(getSuggestedProjectName('foobar')).toBe(undefined)
+        expect(window.showErrorMessage).toBeCalledTimes(1)
+    })
+})