Add project/duplicate endpoint (#10407)

close #10367

Changelog:
- add: `project/duplicate` project manager command to duplicate existing projects
This commit is contained in:
Dmitry Bushev 2024-07-02 14:39:43 +03:00 committed by GitHub
parent 69b5f719e8
commit 764259f36d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 501 additions and 111 deletions

View File

@ -42,6 +42,7 @@ transport formats, please look [here](./protocol-architecture.md).
- [`project/delete`](#projectdelete)
- [`project/listSample`](#projectlistsample)
- [`project/status`](#projectstatus)
- [`project/duplicate`](#projectduplicate)
- [Action Progress Reporting](#action-progress-reporting)
- [`task/started`](#taskstarted)
- [`task/progress-update`](#taskprogress-update)
@ -750,6 +751,50 @@ interface ProjectStatusResponse {
}
```
### `project/duplicate`
This message requests to make a copy of the project.
- **Type:** Request
- **Direction:** Client -> Server
- **Connection:** Protocol
- **Visibility:** Public
#### Parameters
```typescript
interface ProjectDuplicateRequest {
/**
* The project to duplicate.
*/
projectId: UUID;
/**
* Custom directory with the user projects.
*/
projectsDirectory?: string;
}
```
#### Result
```typescript
interface ProjectDuplicateResponse {
projectId: UUID;
projectName: string;
projectNormalizedName: string;
}
```
#### Errors
- [`ProjectDataStoreError`](#projectdatastoreerror) to signal problems with
underlying data store.
- [`ProjectNotFoundError`](#projectnotfounderror) to signal that the project
doesn't exist.
- [`ServiceError`](./protocol-common.md#serviceerror) to signal that the the
operation timed out.
## Action Progress Reporting
Some actions, especially those related to installation of new components may

View File

@ -59,7 +59,7 @@ abstract class JsonRpcServerTestKit
def clientControllerFactory(): ClientControllerFactory
var _clientControllerFactory: ClientControllerFactory = _
private var _clientControllerFactory: ClientControllerFactory = _
override def beforeEach(): Unit = {
super.beforeEach()
@ -67,7 +67,7 @@ abstract class JsonRpcServerTestKit
factory.init()
_clientControllerFactory = clientControllerFactory()
server = new JsonRpcServer(factory, _clientControllerFactory)
binding = Await.result(server.bind(interface, port = 0), 3.seconds)
binding = Await.result(server.bind(interface, port = 0), 5.seconds.dilated)
address = s"ws://$interface:${binding.localAddress.getPort}"
}
@ -174,10 +174,12 @@ abstract class JsonRpcServerTestKit
def fuzzyExpectJson(
json: Json,
timeout: FiniteDuration = 5.seconds.dilated
)(implicit pos: Position): Assertion = {
)(implicit pos: Position): Json = {
val parsed = parse(expectMessage(timeout))
parsed should fuzzyMatchJson(json)
inside(parsed) { case Right(json) => json }
}
def expectNoMessage(): Unit = outActor.expectNoMessage()
@ -191,9 +193,10 @@ abstract class JsonRpcServerTestKit
trait FuzzyJsonMatchers { self: Matchers =>
class JsonEquals(expected: Json)
extends Matcher[Either[io.circe.ParsingFailure, Json]] {
val patch = inferPatch(expected)
def apply(left: Either[io.circe.ParsingFailure, Json]) = {
private val patch = inferPatch(expected)
def apply(left: Either[io.circe.ParsingFailure, Json]): MatchResult = {
val leftFormatted = patch[scala.util.Try](left.getOrElse(Json.Null))
val expectedFormatted = patch[scala.util.Try](expected)
MatchResult(

View File

@ -89,6 +89,20 @@ class BlockingFileSystem[F[+_, +_]: Sync: ErrorChannel](
}
.mapError(toFsFailure)
/** @inheritdoc */
override def copy(from: File, to: File): F[FileSystemFailure, Unit] =
Sync[F]
.blockingOp {
if (to.isDirectory) {
FileUtils.copyToDirectory(from, to)
} else if (from.isDirectory) {
FileUtils.copyDirectory(from, to)
} else {
FileUtils.copyFile(from, to)
}
}
.mapError(toFsFailure)
/** @inheritdoc */
override def exists(file: File): F[FileSystemFailure, Boolean] =
Sync[F]

View File

@ -48,7 +48,7 @@ trait FileSystem[F[+_, +_]] {
*/
def remove(path: File): F[FileSystemFailure, Unit]
/** Move a file or directory recursively
/** Move a file or directory recursively.
*
* @param from a path to the source
* @param to a path to the destination
@ -56,6 +56,14 @@ trait FileSystem[F[+_, +_]] {
*/
def move(from: File, to: File): F[FileSystemFailure, Unit]
/** Copy a file or directory recursively.
*
* @param from a path to the source
* @param to a path to the destination
* @return either [[FileSystemFailure]] or Unit
*/
def copy(from: File, to: File): F[FileSystemFailure, Unit]
/** Tests if a file exists.
*
* @param file the file to check

View File

@ -5,6 +5,7 @@ import java.nio.file.Path
import java.nio.file.attribute.FileTime
import java.util.UUID
import org.enso.pkg.{Package, PackageManager}
import org.enso.pkg.validation.NameValidation
import org.enso.projectmanager.boot.configuration.MetadataStorageConfig
import org.enso.projectmanager.control.core.{
Applicative,
@ -207,14 +208,7 @@ class ProjectFileRepository[
/** @inheritdoc */
def update(project: Project): F[ProjectRepositoryFailure, Unit] =
metadataStorage(project.path)
.persist(
ProjectMetadata(
id = project.id,
kind = project.kind,
created = project.created,
lastOpened = project.lastOpened
)
)
.persist(ProjectMetadata(project))
.mapError(th => StorageFailure(th.toString))
/** @inheritdoc */
@ -231,46 +225,55 @@ class ProjectFileRepository[
}
/** @inheritdoc */
override def moveProjectToTargetDir(
override def moveProject(
projectId: UUID,
newName: String
): F[ProjectRepositoryFailure, File] = {
def move(project: Project) =
for {
targetPath <- findTargetPath(newName)
_ <- moveProjectDir(project, targetPath)
targetPath <- findTargetPath(NameValidation.normalizeName(newName))
_ <- moveProjectDir(project.path, targetPath)
} yield targetPath
for {
project <- getProject(projectId)
primaryPath = new File(projectsPath, newName)
finalPath <-
if (isLocationOk(project.path, primaryPath)) {
CovariantFlatMap[F].pure(primaryPath)
} else {
move(project)
}
} yield finalPath
project <- getProject(projectId)
projectPath <- move(project)
} yield projectPath
}
private def isLocationOk(
currentFile: File,
primaryFile: File
): Boolean = {
val currentPath = currentFile.toString
val primaryPath = primaryFile.toString
if (currentPath.startsWith(primaryPath)) {
val suffixPattern = "_\\d+"
val suffix = currentPath.substring(primaryPath.length, currentPath.length)
suffix.matches(suffixPattern)
} else {
false
}
/** @inheritdoc */
override def copyProject(
project: Project,
newName: String,
newMetadata: ProjectMetadata
): F[ProjectRepositoryFailure, Project] = {
def copy(project: Project) =
for {
targetPath <- findTargetPath(NameValidation.normalizeName(newName))
_ <- copyProjectDir(project.path, targetPath)
} yield targetPath
for {
newProjectPath <- copy(project)
_ <- metadataStorage(newProjectPath)
.persist(newMetadata)
.mapError(th => StorageFailure(th.toString))
_ <- renamePackage(newProjectPath, newName)
newProject <- getProject(newMetadata.id)
} yield newProject
}
private def moveProjectDir(project: Project, targetPath: File) = {
private def moveProjectDir(projectPath: File, targetPath: File) = {
fileSystem
.move(project.path, targetPath)
.move(projectPath, targetPath)
.mapError[ProjectRepositoryFailure](failure =>
StorageFailure(failure.toString)
)
}
private def copyProjectDir(projectPath: File, targetPath: File) = {
fileSystem
.copy(projectPath, targetPath)
.mapError[ProjectRepositoryFailure](failure =>
StorageFailure(failure.toString)
)

View File

@ -3,8 +3,7 @@ package org.enso.projectmanager.infrastructure.repository
import java.io.File
import java.nio.file.Path
import java.util.UUID
import org.enso.projectmanager.model.Project
import org.enso.projectmanager.model.{Project, ProjectMetadata}
/** An abstraction for accessing project domain objects from durable storage.
*
@ -82,11 +81,23 @@ trait ProjectRepository[F[+_, +_]] {
* @param projectId the project id
* @param newName the new project name
*/
def moveProjectToTargetDir(
def moveProject(
projectId: UUID,
newName: String
): F[ProjectRepositoryFailure, File]
/** Create a copy of the project.
*
* @param project the project to copy
* @param newName the new project name
* @param newMetadata the new project metadata
*/
def copyProject(
project: Project,
newName: String,
newMetadata: ProjectMetadata
): F[ProjectRepositoryFailure, Project]
/** Gets a package name for the specified project.
*
* @param projectId the project id

View File

@ -86,6 +86,11 @@ class ClientController[F[+_, +_]: Exec: CovariantFlatMap: ErrorChannel: Sync](
),
ProjectRename -> ProjectRenameHandler
.props[F](projectService, timeoutConfig.requestTimeout),
ProjectDuplicate -> ProjectDuplicateHandler.props[F](
projectService,
timeoutConfig.requestTimeout,
timeoutConfig.retries
),
EngineListInstalled -> EngineListInstalledHandler.props(
runtimeVersionManagementService,
timeoutConfig.requestTimeout

View File

@ -26,6 +26,7 @@ object JsonRpc {
.registerRequest(ProjectClose)
.registerRequest(ProjectRename)
.registerRequest(ProjectList)
.registerRequest(ProjectDuplicate)
.registerNotification(TaskStarted)
.registerNotification(TaskProgressUpdate)
.registerNotification(TaskFinished)

View File

@ -83,6 +83,27 @@ object ProjectManagementApi {
}
}
case object ProjectDuplicate extends Method("project/duplicate") {
case class Params(projectId: UUID, projectsDirectory: Option[String])
case class Result(
projectId: UUID,
projectName: String,
projectNormalizedName: String
)
implicit val hasParams: HasParams.Aux[this.type, ProjectDuplicate.Params] =
new HasParams[this.type] {
type Params = ProjectDuplicate.Params
}
implicit val hasResult: HasResult.Aux[this.type, ProjectDuplicate.Result] =
new HasResult[this.type] {
type Result = ProjectDuplicate.Result
}
}
case object ProjectOpen extends Method("project/open") {
case class Params(

View File

@ -0,0 +1,83 @@
package org.enso.projectmanager.requesthandler
import akka.actor._
import org.enso.projectmanager.control.core.CovariantFlatMap
import org.enso.projectmanager.control.core.syntax._
import org.enso.projectmanager.control.effect.{Exec, Sync}
import org.enso.projectmanager.infrastructure.file.Files
import org.enso.projectmanager.protocol.ProjectManagementApi.ProjectDuplicate
import org.enso.projectmanager.service.{
ProjectServiceApi,
ProjectServiceFailure
}
import scala.concurrent.duration.FiniteDuration
/** A request handler for `project/duplicate` commands.
*
* @param projectService a project service
* @param requestTimeout a request timeout
* @param timeoutRetries a number of timeouts to wait until a failure is reported
*/
class ProjectDuplicateHandler[
F[+_, +_]: Exec: CovariantFlatMap: Sync
](
projectService: ProjectServiceApi[F],
requestTimeout: FiniteDuration,
timeoutRetries: Int
) extends RequestHandler[
F,
ProjectServiceFailure,
ProjectDuplicate.type,
ProjectDuplicate.Params,
ProjectDuplicate.Result
](
ProjectDuplicate,
Some(requestTimeout),
timeoutRetries
) {
override def handleRequest: ProjectDuplicate.Params => F[
ProjectServiceFailure,
ProjectDuplicate.Result
] = { params =>
for {
projectsDirectory <- Sync[F].effect(
params.projectsDirectory.map(Files.getAbsoluteFile)
)
project <- projectService.duplicateUserProject(
projectId = params.projectId,
projectsDirectory = projectsDirectory
)
_ = logger.trace(
"Duplicated project [{}] with the new name [{}].",
params.projectId,
project.name
)
} yield ProjectDuplicate.Result(project.id, project.name, project.module)
}
}
object ProjectDuplicateHandler {
/** Creates a configuration object used to create a [[ProjectDuplicateHandler]].
*
* @param projectService a project service
* @param requestTimeout a request timeout
* @param timeoutRetries a number of timeouts to wait until a failure is reported
* @return a configuration object
*/
def props[F[+_, +_]: Exec: CovariantFlatMap: Sync](
projectService: ProjectServiceApi[F],
requestTimeout: FiniteDuration,
timeoutRetries: Int
): Props =
Props(
new ProjectDuplicateHandler(
projectService,
requestTimeout,
timeoutRetries
)
)
}

View File

@ -32,7 +32,7 @@ class MoveProjectDirCmd[F[+_, +_]: CovariantFlatMap: ErrorChannel](
def go() =
for {
_ <- log.debug("Moving project [{}] to [{}].", projectId, newName)
dir <- repo.moveProjectToTargetDir(projectId, newName)
dir <- repo.moveProject(projectId, newName)
_ <- log.info("Project [{}] moved to [{}].", projectId, dir)
} yield ()

View File

@ -33,6 +33,7 @@ import org.enso.projectmanager.infrastructure.repository.{
ProjectRepositoryFailure
}
import org.enso.projectmanager.infrastructure.time.Clock
import org.enso.projectmanager.model
import org.enso.projectmanager.model.Project
import org.enso.projectmanager.model.ProjectKinds.UserProject
import org.enso.projectmanager.service.ProjectServiceFailure._
@ -96,7 +97,7 @@ class ProjectService[
projectsDirectory
)
repo = projectRepositoryFactory.getProjectRepository(projectsDirectory)
name <- getNameForNewProject(projectName, projectTemplate, repo)
name <- getNameForNewProject(projectName, repo)
_ <- log.info("Created project with actual name [{}].", name)
_ <- validateProjectName(name)
_ <- checkIfNameExists(name, repo)
@ -373,6 +374,33 @@ class ProjectService[
}
}
/** @inheritdoc */
override def duplicateUserProject(
projectId: UUID,
projectsDirectory: Option[File]
): F[ProjectServiceFailure, Project] =
for {
_ <- log.debug("Duplicating project [{}].", projectId)
repo = projectRepositoryFactory.getProjectRepository(projectsDirectory)
project <- getUserProject(projectId, repo)
suggestedProjectName = getNameForDuplicatedProject(project.name)
newName <- getNameForNewProject(suggestedProjectName, repo)
_ <- validateProjectName(newName)
_ <- log.debug("Validated new project name [{}]", newName)
repo = projectRepositoryFactory.getProjectRepository(projectsDirectory)
createdTime <- clock.nowInUtc()
newMetadata = model.ProjectMetadata(
id = UUID.randomUUID(),
kind = project.kind,
created = createdTime,
lastOpened = None
)
newProject <- repo
.copyProject(project, newName, newMetadata)
.mapError(toServiceFailure)
_ <- log.info("Project copied [{}].", newProject)
} yield newProject
/** @inheritdoc */
override def listProjects(
maybeSize: Option[Int]
@ -474,7 +502,6 @@ class ProjectService[
private def getNameForNewProject(
projectName: String,
projectTemplate: Option[String],
projectRepository: ProjectRepository[F]
): F[ProjectServiceFailure, String] = {
def mkName(name: String, suffix: Int): String =
@ -490,20 +517,17 @@ class ProjectService[
)
}
projectTemplate match {
case Some(_) =>
CovariantFlatMap[F]
.ifM(projectRepository.exists(projectName))(
ifTrue = findAvailableName(projectName, 1),
ifFalse = CovariantFlatMap[F].pure(projectName)
)
.mapError(toServiceFailure)
case None =>
CovariantFlatMap[F].pure(projectName)
}
CovariantFlatMap[F]
.ifM(projectRepository.exists(projectName))(
ifTrue = findAvailableName(projectName, 1),
ifFalse = CovariantFlatMap[F].pure(projectName)
)
.mapError(toServiceFailure)
}
private def getNameForDuplicatedProject(projectName: String): String =
s"$projectName (copy)"
/** Retrieve project info.
*
* @param clientId the requester id

View File

@ -97,6 +97,17 @@ trait ProjectServiceApi[F[+_, +_]] {
projectId: UUID
): F[ProjectServiceFailure, Unit]
/** Duplicate an existing project.
*
* @param projectId the project to copy
* @param projectsDirectory the path to the projects directory
* @return the new duplicated project
*/
def duplicateUserProject(
projectId: UUID,
projectsDirectory: Option[File]
): F[ProjectServiceFailure, Project]
/** Lists the user's most recently opened projects..
*
* @param maybeSize the size of result set

View File

@ -56,6 +56,12 @@ class FileSystemService[F[+_, +_]: Applicative: CovariantFlatMap: ErrorChannel](
.move(from, to)
.mapError(_ => FileSystemServiceFailure.FileSystem("Failed to move path"))
/** @inheritdoc */
override def copy(from: File, to: File): F[FileSystemServiceFailure, Unit] =
fileSystem
.copy(from, to)
.mapError(_ => FileSystemServiceFailure.FileSystem("Failed to copy path"))
/** @inheritdoc */
override def write(
path: File,

View File

@ -30,6 +30,13 @@ trait FileSystemServiceApi[F[+_, +_]] {
*/
def move(from: File, to: File): F[FileSystemServiceFailure, Unit]
/** Copy a file or directory recursively.
*
* @param from the target path
* @param to the destination path
*/
def copy(from: File, to: File): F[FileSystemServiceFailure, Unit]
/** Writes a file
*
* @param path the file path to write

View File

@ -116,53 +116,6 @@ class ProjectManagementApiSpec
""")
}
"fail when the project with the same name exists" in {
implicit val client: WsTestClient = new WsTestClient(address)
client.send(json"""
{ "jsonrpc": "2.0",
"method": "project/create",
"id": 1,
"params": {
"name": "Foo"
}
}
""")
val projectId = getGeneratedUUID
client.expectJson(json"""
{
"jsonrpc" : "2.0",
"id" : 1,
"result" : {
"projectId" : $projectId,
"projectName" : "Foo",
"projectNormalizedName": "Foo"
}
}
""")
client.send(json"""
{ "jsonrpc": "2.0",
"method": "project/create",
"id": 2,
"params": {
"name": "Foo"
}
}
""")
client.expectJson(json"""
{
"jsonrpc":"2.0",
"id":2,
"error":{
"code":4003,
"message":"Project with the provided name exists"
}
}
""")
//teardown
deleteProject(projectId)
}
"create project structure" in {
val projectName = "Foo"
@ -276,6 +229,27 @@ class ProjectManagementApiSpec
deleteProject(projectId)
}
"find a name when project with the same name exists" in {
val projectName = "Foo"
implicit val client: WsTestClient = new WsTestClient(address)
val projectId1 = createProject(projectName)
val projectId2 = createProject(
projectName,
nameSuffix = Some(1)
)
val projectDir = new File(userProjectDir, "Foo_1")
val packageFile = new File(projectDir, "package.yaml")
Files.readAllLines(packageFile.toPath) contains "name: Foo_1"
//teardown
deleteProject(projectId1)
deleteProject(projectId2)
}
"find a name when project is created from template" in {
val projectName = "Foo"
@ -307,7 +281,7 @@ class ProjectManagementApiSpec
"id": 1,
"params": {
"name": "Foo",
"version": ${CurrentVersion.version.toString()}
"version": ${CurrentVersion.version.toString}
}
}
""")
@ -582,13 +556,14 @@ class ProjectManagementApiSpec
"fail when project's edition could not be resolved" in {
pending
implicit val client = new WsTestClient(address)
val projectId = createProject("Foo")
implicit val client: WsTestClient = new WsTestClient(address)
//given
val projectId = createProject("Foo")
setProjectParentEdition(
"Foo",
"some_weird_edition_name_that-surely-does-not-exist"
)
//when
client.send(json"""
{ "jsonrpc": "2.0",
"method": "project/open",
@ -598,6 +573,7 @@ class ProjectManagementApiSpec
}
}
""")
//then
client.expectJson(json"""
{
"jsonrpc":"2.0",
@ -608,7 +584,7 @@ class ProjectManagementApiSpec
}
}
""")
//teardown
deleteProject(projectId)
}
@ -1347,4 +1323,130 @@ class ProjectManagementApiSpec
deleteProject(projectId)
}
}
"project/duplicate" must {
"duplicate a project" in {
implicit val client: WsTestClient = new WsTestClient(address)
//given
val projectName = "Project To Copy"
val projectId = createProject(projectName)
//when
client.send(json"""
{ "jsonrpc": "2.0",
"method": "project/duplicate",
"id": 0,
"params": {
"projectId": $projectId
}
}
""")
//then
val newProjectName = "Project To Copy (copy)"
val duplicateReply = client.fuzzyExpectJson(json"""
{
"jsonrpc": "2.0",
"id": 0,
"result": {
"projectId": "*",
"projectName": $newProjectName,
"projectNormalizedName": "ProjectToCopycopy"
}
}
""")
val Some(duplicatedProjectId) = for {
reply <- duplicateReply.asObject
resultJson <- reply("result")
result <- resultJson.asObject
projectIdJson <- result("projectId")
projectId <- projectIdJson.asString
} yield UUID.fromString(projectId)
{
val projectDir = new File(userProjectDir, "ProjectToCopycopy")
val packageFile = new File(projectDir, "package.yaml")
val buffer = Source.fromFile(packageFile)
try {
val lines = buffer.getLines()
lines.contains(s"name: $newProjectName") shouldBe true
} finally {
buffer.close()
}
}
//when
client.send(json"""
{ "jsonrpc": "2.0",
"method": "project/duplicate",
"id": 0,
"params": {
"projectId": $projectId
}
}
""")
//then
val newProjectName1 = "Project To Copy (copy)_1"
val duplicateReply1 = client.fuzzyExpectJson(json"""
{
"jsonrpc": "2.0",
"id": 0,
"result": {
"projectId": "*",
"projectName": $newProjectName1,
"projectNormalizedName": "ProjectToCopycopy_1"
}
}
""")
val Some(duplicatedProjectId1) = for {
reply <- duplicateReply1.asObject
resultJson <- reply("result")
result <- resultJson.asObject
projectIdJson <- result("projectId")
projectId <- projectIdJson.asString
} yield UUID.fromString(projectId)
{
val projectDir = new File(userProjectDir, "ProjectToCopycopy_1")
val packageFile = new File(projectDir, "package.yaml")
val buffer = Source.fromFile(packageFile)
try {
val lines = buffer.getLines()
lines.contains(s"name: $newProjectName1") shouldBe true
} finally {
buffer.close()
}
}
//teardown
deleteProject(duplicatedProjectId)
deleteProject(duplicatedProjectId1)
deleteProject(projectId)
}
"fail when project doesn't exist" in {
val client = new WsTestClient(address)
client.send(json"""
{ "jsonrpc": "2.0",
"method": "project/duplicate",
"id": 1,
"params": {
"projectId": ${UUID.randomUUID()}
}
}
""")
client.expectJson(json"""
{
"jsonrpc":"2.0",
"id":1,
"error":{
"code":4004,
"message":"Project with the provided id does not exist"
}
}
""")
}
}
}

View File

@ -183,6 +183,52 @@ class FileSystemServiceSpec
FileUtils.deleteQuietly(targetPath)
}
"copy file" in {
val testDir = testStorageConfig.userProjectsPath
val targetFileName = "target_copy_file.txt"
val destinationFileName = "destination_copy_file.txt"
val targetFilePath = new File(testDir, targetFileName)
val destinationFilePath = new File(testDir, destinationFileName)
FileUtils.forceMkdirParent(targetFilePath)
FileUtils.touch(targetFilePath)
fileSystemService
.copy(targetFilePath, destinationFilePath)
.unsafeRunSync()
Files.exists(targetFilePath.toPath) shouldEqual true
Files.exists(destinationFilePath.toPath) shouldEqual true
// cleanup
FileUtils.deleteQuietly(targetFilePath)
FileUtils.deleteQuietly(destinationFilePath)
}
"copy directory" in {
implicit val client: WsTestClient = new WsTestClient(address)
val testDir = testStorageConfig.userProjectsPath
val projectName = "New_Project_To_Copy"
createProject(projectName)
val directoryPath = new File(testDir, projectName)
val targetPath = new File(testDir, "Target_Copy_Directory")
fileSystemService
.copy(directoryPath, targetPath)
.unsafeRunSync()
Files.exists(directoryPath.toPath) shouldEqual true
Files.isDirectory(targetPath.toPath) shouldEqual true
// cleanup
FileUtils.deleteQuietly(directoryPath)
FileUtils.deleteQuietly(targetPath)
}
"write path" in {
val testDir = testStorageConfig.userProjectsPath