Creating a new database project, project items

* can create, open, and close sqlproj files
* can add sql objects to projects
This commit is contained in:
Benjin Dubishar
2020-04-17 14:09:59 -07:00
committed by GitHub
parent 05526bbaca
commit b3492e3f57
34 changed files with 1782 additions and 94 deletions

View File

@@ -9,6 +9,7 @@ const localize = nls.loadMessageBundle();
// Placeholder values
export const dataSourcesFileName = 'datasources.json';
export const sqlprojExtension = '.sqlproj';
// UI Strings
@@ -16,6 +17,9 @@ export const noOpenProjectMessage = localize('noProjectOpenMessage', "No open da
export const projectNodeName = localize('projectNodeName', "Database Project");
export const dataSourcesNodeName = localize('dataSourcesNodeName', "Data Sources");
export const sqlConnectionStringFriendly = localize('sqlConnectionStringFriendly', "SQL connection string");
export const newDatabaseProjectName = localize('newDatabaseProjectName', "New database project name:");
export const sqlDatabaseProject = localize('sqlDatabaseProject', "SQL database project");
export function newObjectNamePrompt(objectType: string) { return localize('newObjectNamePrompt', 'New {0} name:', objectType); }
// Error messages
@@ -26,3 +30,21 @@ export const missingVersion = localize('missingVersion', "Missing 'version' entr
export const unrecognizedDataSourcesVersion = localize('unrecognizedDataSourcesVersion', "Unrecognized version: ");
export const unknownDataSourceType = localize('unknownDataSourceType', "Unknown data source type: ");
export const invalidSqlConnectionString = localize('invalidSqlConnectionString', "Invalid SQL connection string");
export const projectNameRequired = localize('projectNameRequired', "Name is required to create a new database project.");
export const projectLocationRequired = localize('projectLocationRequired', "Location is required to create a new database project.");
export function projectAlreadyOpened(path: string) { return localize('projectAlreadyOpened', "Project '{0}' is already opened.", path); }
export function projectAlreadyExists(name: string, path: string) { return localize('projectAlreadyExists', "A project named {0} already exists in {1}.", name, path); }
// Project script types
export const scriptFriendlyName = localize('scriptFriendlyName', "Script");
export const tableFriendlyName = localize('tableFriendlyName', "Table");
export const viewFriendlyName = localize('viewFriendlyName', "View");
export const storedProcedureFriendlyName = localize('storedProcedureFriendlyName', "Stored Procedure");
// SqlProj file XML names
export const ItemGroup = 'ItemGroup';
export const Build = 'Build';
export const Folder = 'Folder';
export const Include = 'Include';

View File

@@ -6,7 +6,7 @@
import * as vscode from 'vscode';
import * as constants from '../common/constants';
import { BaseProjectTreeItem, MessageTreeItem } from '../models/tree/baseTreeItem';
import { BaseProjectTreeItem, MessageTreeItem, SpacerTreeItem } from '../models/tree/baseTreeItem';
import { ProjectRootTreeItem } from '../models/tree/projectTreeItem';
import { Project } from '../models/project';
@@ -39,16 +39,20 @@ export class SqlDatabaseProjectTreeViewProvider implements vscode.TreeDataProvid
return element.children;
}
/**
* Constructs a new set of root nodes from a list of Projects
* @param projects List of Projects
*/
public load(projects: Project[]) {
if (projects.length === 0) {
vscode.window.showErrorMessage(constants.noSqlProjFiles);
return;
}
let newRoots: BaseProjectTreeItem[] = [];
for (const proj of projects) {
newRoots.push(new ProjectRootTreeItem(proj));
newRoots.push(SpacerTreeItem);
}
if (newRoots[newRoots.length - 1] === SpacerTreeItem) {
newRoots.pop(); // get rid of the trailing SpacerTreeItem
}
this.roots = newRoots;

View File

@@ -4,16 +4,18 @@
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import * as nls from 'vscode-nls';
import * as templateMap from '../templates/templateMap';
import * as templates from '../templates/templates';
import * as constants from '../common/constants';
import * as path from 'path';
import { SqlDatabaseProjectTreeViewProvider } from './databaseProjectTreeViewProvider';
import { getErrorMessage } from '../common/utils';
import { ProjectsController } from './projectController';
import { BaseProjectTreeItem } from '../models/tree/baseTreeItem';
const SQL_DATABASE_PROJECTS_VIEW_ID = 'sqlDatabaseProjectsView';
const localize = nls.loadMessageBundle();
/**
* The main controller class that initializes the extension
*/
@@ -40,11 +42,21 @@ export default class MainController implements vscode.Disposable {
private async initializeDatabaseProjects(): Promise<void> {
// init commands
vscode.commands.registerCommand('sqlDatabaseProjects.new', () => { console.log('"New Database Project" called.'); });
vscode.commands.registerCommand('sqlDatabaseProjects.open', async () => { this.openProjectFromFile(); });
vscode.commands.registerCommand('sqlDatabaseProjects.new', async () => { await this.createNewProject(); });
vscode.commands.registerCommand('sqlDatabaseProjects.open', async () => { await this.openProjectFromFile(); });
vscode.commands.registerCommand('sqlDatabaseProjects.close', (node: BaseProjectTreeItem) => { this.projectsController.closeProject(node); });
vscode.commands.registerCommand('sqlDatabaseProjects.newScript', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPrompt(node, templateMap.script); });
vscode.commands.registerCommand('sqlDatabaseProjects.newTable', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPrompt(node, templateMap.table); });
vscode.commands.registerCommand('sqlDatabaseProjects.newView', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPrompt(node, templateMap.view); });
vscode.commands.registerCommand('sqlDatabaseProjects.newStoredProcedure', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPrompt(node, templateMap.storedProcedure); });
vscode.commands.registerCommand('sqlDatabaseProjects.newItem', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPrompt(node); });
vscode.commands.registerCommand('sqlDatabaseProjects.newFolder', async (node: BaseProjectTreeItem) => { await this.projectsController.addFolderPrompt(node); });
// init view
this.extensionContext.subscriptions.push(vscode.window.registerTreeDataProvider(SQL_DATABASE_PROJECTS_VIEW_ID, this.dbProjectTreeViewProvider));
await templates.loadTemplates(path.join(this._context.extensionPath, 'resources', 'templates'));
}
/**
@@ -55,7 +67,7 @@ export default class MainController implements vscode.Disposable {
try {
let filter: { [key: string]: string[] } = {};
filter[localize('sqlDatabaseProject', "SQL database project")] = ['sqlproj'];
filter[constants.sqlDatabaseProject] = ['sqlproj'];
let files: vscode.Uri[] | undefined = await vscode.window.showOpenDialog({ filters: filter });
@@ -70,6 +82,47 @@ export default class MainController implements vscode.Disposable {
}
}
/**
* Creates a new SQL database project from a template, prompting the user for a name and location
*/
public async createNewProject(): Promise<void> {
try {
let newProjName = await vscode.window.showInputBox({
prompt: constants.newDatabaseProjectName,
value: `DatabaseProject${this.projectsController.projects.length + 1}`
// TODO: Smarter way to suggest a name. Easy if we prompt for location first, but that feels odd...
});
if (!newProjName) {
// TODO: is this case considered an intentional cancellation (shouldn't warn) or an error case (should warn)?
vscode.window.showErrorMessage(constants.projectNameRequired);
return;
}
let selectionResult = await vscode.window.showOpenDialog({
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
defaultUri: vscode.workspace.workspaceFolders ? vscode.workspace.workspaceFolders[0].uri : undefined
});
if (!selectionResult) {
vscode.window.showErrorMessage(constants.projectLocationRequired);
return;
}
// TODO: what if the selected folder is outside the workspace?
const newProjFolderUri = (selectionResult as vscode.Uri[])[0];
console.log(newProjFolderUri.fsPath);
const newProjFilePath = await this.projectsController.createNewProject(newProjName as string, newProjFolderUri as vscode.Uri);
await this.projectsController.openProject(vscode.Uri.file(newProjFilePath));
}
catch (err) {
vscode.window.showErrorMessage(getErrorMessage(err));
}
}
public dispose(): void {
this.deactivate();
}

View File

@@ -4,11 +4,20 @@
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { Project } from '../models/project';
import { SqlDatabaseProjectTreeViewProvider } from './databaseProjectTreeViewProvider';
import * as path from 'path';
import * as constants from '../common/constants';
import * as dataSources from '../models/dataSources/dataSources';
import * as templateMap from '../templates/templateMap';
import * as utils from '../common/utils';
import * as UUID from 'vscode-languageclient/lib/utils/uuid';
import * as templates from '../templates/templates';
import { Project } from '../models/project';
import { SqlDatabaseProjectTreeViewProvider } from './databaseProjectTreeViewProvider';
import { promises as fs } from 'fs';
import { BaseProjectTreeItem } from '../models/tree/baseTreeItem';
import { ProjectRootTreeItem } from '../models/tree/projectTreeItem';
import { FolderNode } from '../models/tree/fileFolderTreeItem';
/**
* Controller for managing project lifecycle
@@ -22,8 +31,18 @@ export class ProjectsController {
this.projectTreeViewProvider = projTreeViewProvider;
}
public async openProject(projectFile: vscode.Uri) {
console.log('Loading project: ' + projectFile.fsPath);
public refreshProjectsTree() {
this.projectTreeViewProvider.load(this.projects);
}
public async openProject(projectFile: vscode.Uri): Promise<Project> {
for (const proj of this.projects) {
if (proj.projectFilePath === projectFile.fsPath) {
vscode.window.showInformationMessage(constants.projectAlreadyOpened(projectFile.fsPath));
return proj;
}
}
// Read project file
const newProject = new Project(projectFile.fsPath);
@@ -32,12 +51,173 @@ export class ProjectsController {
// Read datasources.json (if present)
const dataSourcesFilePath = path.join(path.dirname(projectFile.fsPath), constants.dataSourcesFileName);
newProject.dataSources = await dataSources.load(dataSourcesFilePath);
try {
newProject.dataSources = await dataSources.load(dataSourcesFilePath);
}
catch (err) {
if (err instanceof dataSources.NoDataSourcesFileError) {
// TODO: prompt to create new datasources.json; for now, swallow
console.log(`No ${constants.dataSourcesFileName} file found.`);
}
else {
throw err;
}
}
this.refreshProjectsTree();
return newProject;
}
public async createNewProject(newProjName: string, folderUri: vscode.Uri, projectGuid?: string): Promise<string> {
if (projectGuid && !UUID.isUUID(projectGuid)) {
throw new Error(`Specified GUID is invalid: '${projectGuid}'`);
}
const macroDict: Record<string, string> = {
'PROJECT_NAME': newProjName,
'PROJECT_GUID': projectGuid ?? UUID.generateUuid().toUpperCase()
};
let newProjFileContents = this.macroExpansion(templates.newSqlProjectTemplate, macroDict);
let newProjFileName = newProjName;
if (!newProjFileName.toLowerCase().endsWith(constants.sqlprojExtension)) {
newProjFileName += constants.sqlprojExtension;
}
const newProjFilePath = path.join(folderUri.fsPath, newProjFileName);
let fileExists = false;
try {
await fs.access(newProjFilePath);
fileExists = true;
}
catch { } // file doesn't already exist
if (fileExists) {
throw new Error(constants.projectAlreadyExists(newProjFileName, folderUri.fsPath));
}
await fs.mkdir(path.dirname(newProjFilePath), { recursive: true });
await fs.writeFile(newProjFilePath, newProjFileContents);
return newProjFilePath;
}
public closeProject(treeNode: BaseProjectTreeItem) {
const project = this.getProjectContextFromTreeNode(treeNode);
this.projects = this.projects.filter((e) => { return e !== project; });
this.refreshProjectsTree();
}
public async addFolderPrompt(treeNode: BaseProjectTreeItem) {
const project = this.getProjectContextFromTreeNode(treeNode);
const newFolderName = await this.promptForNewObjectName(new templateMap.ProjectScriptType(templateMap.folder, 'Folder', ''), project);
if (!newFolderName) {
return; // user cancelled
}
const relativeFolderPath = this.prependContextPath(treeNode, newFolderName);
await project.addFolderItem(relativeFolderPath);
this.refreshProjectsTree();
}
public refreshProjectsTree() {
this.projectTreeViewProvider.load(this.projects);
public async addItemPrompt(treeNode: BaseProjectTreeItem, itemTypeName?: string) {
const project = this.getProjectContextFromTreeNode(treeNode);
if (!itemTypeName) {
let itemFriendlyNames: string[] = [];
for (const itemType of templateMap.projectScriptTypes) {
itemFriendlyNames.push(itemType.friendlyName);
}
itemTypeName = await vscode.window.showQuickPick(itemFriendlyNames, {
canPickMany: false
});
if (!itemTypeName) {
return; // user cancelled
}
}
const itemType = templateMap.projectScriptTypeMap[itemTypeName.toLocaleLowerCase()];
const itemObjectName = await this.promptForNewObjectName(itemType, project);
if (!itemObjectName) {
return; // user cancelled
}
// TODO: file already exists?
const newFileText = this.macroExpansion(itemType.templateScript, { 'OBJECT_NAME': itemObjectName });
const relativeFilePath = this.prependContextPath(treeNode, itemObjectName + '.sql');
const newEntry = await project.addScriptItem(relativeFilePath, newFileText);
vscode.commands.executeCommand('vscode.open', newEntry.fsUri);
this.refreshProjectsTree();
}
//#region Helper methods
private macroExpansion(template: string, macroDict: Record<string, string>): string {
const macroIndicator = '@@';
let output = template;
for (const macro in macroDict) {
// check if value contains the macroIndicator, which could break expansion for successive macros
if (macroDict[macro].includes(macroIndicator)) {
throw new Error(`Macro value ${macroDict[macro]} is invalid because it contains ${macroIndicator}`);
}
output = output.replace(new RegExp(macroIndicator + macro + macroIndicator, 'g'), macroDict[macro]);
}
return output;
}
private getProjectContextFromTreeNode(treeNode: BaseProjectTreeItem): Project {
if (!treeNode) {
// TODO: prompt for which (currently-open) project when invoked via command pallet
throw new Error('TODO: prompt for which project when invoked via command pallet');
}
if (treeNode.root instanceof ProjectRootTreeItem) {
return (treeNode.root as ProjectRootTreeItem).project;
}
else {
throw new Error('"Add item" command invoked from unexpected location: ' + treeNode.uri.path);
}
}
private async promptForNewObjectName(itemType: templateMap.ProjectScriptType, _project: Project): Promise<string | undefined> {
// TODO: ask project for suggested name that doesn't conflict
const suggestedName = itemType.friendlyName.replace(new RegExp('\s', 'g'), '') + '1';
const itemObjectName = await vscode.window.showInputBox({
prompt: constants.newObjectNamePrompt(itemType.friendlyName),
value: suggestedName,
});
return itemObjectName;
}
private prependContextPath(treeNode: BaseProjectTreeItem, objectName: string): string {
if (treeNode instanceof FolderNode) {
return path.join(utils.trimUri(treeNode.root.uri, treeNode.uri), objectName);
}
else {
return objectName;
}
}
//#endregion
}

View File

@@ -13,13 +13,21 @@ import { SqlConnectionDataSource } from './sqlConnectionStringSource';
export abstract class DataSource {
public name: string;
public abstract get type(): string;
public abstract get friendlyName(): string;
public abstract get typeFriendlyName(): string;
constructor(name: string) {
this.name = name;
}
}
export class NoDataSourcesFileError extends Error {
constructor(message?: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
this.name = NoDataSourcesFileError.name;
}
}
/**
* parses the specified file to load DataSource objects
*/
@@ -30,7 +38,8 @@ export async function load(dataSourcesFilePath: string): Promise<DataSource[]> {
fileContents = await fs.readFile(dataSourcesFilePath);
}
catch (err) {
throw new Error(constants.noDataSourcesFile);
// TODO: differentiate between file not existing and other types of failures; need to know whether to prompt to create new
throw new NoDataSourcesFileError(constants.noDataSourcesFile);
}
const rawJsonContents = JSON.parse(fileContents.toString());

View File

@@ -21,7 +21,7 @@ export class SqlConnectionDataSource extends DataSource {
return SqlConnectionDataSource.type;
}
public get friendlyName(): string {
public get typeFriendlyName(): string {
return constants.sqlConnectionStringFriendly;
}
@@ -42,6 +42,10 @@ export class SqlConnectionDataSource extends DataSource {
}
}
public getSetting(settingName: string): string {
return this.connectionStringComponents[settingName];
}
public static fromJson(json: DataSourceJson): SqlConnectionDataSource {
return new SqlConnectionDataSource(json.name, (json.data as unknown as SqlConnectionDataSourceJson).connectionString);
}

View File

@@ -4,39 +4,40 @@
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import * as xml2js from 'xml2js';
import * as path from 'path';
import * as xmldom from 'xmldom';
import * as constants from '../common/constants';
import { promises as fs } from 'fs';
import { DataSource } from './dataSources/dataSources';
import { getErrorMessage } from '../common/utils';
/**
* Class representing a Project, and providing functions for operating on it
*/
export class Project {
public projectFile: string;
public projectFilePath: string;
public files: ProjectEntry[] = [];
public dataSources: DataSource[] = [];
public get projectFolderPath() {
return path.dirname(this.projectFilePath);
}
private projFileXmlDoc: any = undefined;
constructor(projectFilePath: string) {
this.projectFile = projectFilePath;
this.projectFilePath = projectFilePath;
}
/**
* Reads the project setting and contents from the file
*/
public async readProjFile() {
let projFileContents = await fs.readFile(this.projectFile);
const parser = new xml2js.Parser({
explicitArray: true,
explicitCharkey: false,
explicitRoot: false
});
let result;
const projFileText = await fs.readFile(this.projectFilePath);
try {
result = await parser.parseStringPromise(projFileContents.toString());
this.projFileXmlDoc = new xmldom.DOMParser().parseFromString(projFileText.toString());
}
catch (err) {
vscode.window.showErrorMessage(err);
@@ -45,23 +46,115 @@ export class Project {
// find all folders and files to include
for (const itemGroup of result['ItemGroup']) {
if (itemGroup['Build'] !== undefined) {
for (const fileEntry of itemGroup['Build']) {
this.files.push(this.createProjectEntry(fileEntry.$['Include'], EntryType.File));
}
for (let ig = 0; ig < this.projFileXmlDoc.documentElement.getElementsByTagName(constants.ItemGroup).length; ig++) {
const itemGroup = this.projFileXmlDoc.documentElement.getElementsByTagName(constants.ItemGroup)[ig];
for (let b = 0; b < itemGroup.getElementsByTagName(constants.Build).length; b++) {
this.files.push(this.createProjectEntry(itemGroup.getElementsByTagName(constants.Build)[b].getAttribute(constants.Include), EntryType.File));
}
if (itemGroup['Folder'] !== undefined) {
for (const folderEntry of itemGroup['Folder']) {
this.files.push(this.createProjectEntry(folderEntry.$['Include'], EntryType.Folder));
}
for (let f = 0; f < itemGroup.getElementsByTagName(constants.Folder).length; f++) {
this.files.push(this.createProjectEntry(itemGroup.getElementsByTagName(constants.Folder)[f].getAttribute(constants.Include), EntryType.Folder));
}
}
}
/**
* Adds a folder to the project, and saves the project file
* @param relativeFolderPath Relative path of the folder
*/
public async addFolderItem(relativeFolderPath: string): Promise<ProjectEntry> {
const absoluteFolderPath = path.join(this.projectFolderPath, relativeFolderPath);
await fs.mkdir(absoluteFolderPath, { recursive: true });
const folderEntry = this.createProjectEntry(relativeFolderPath, EntryType.Folder);
this.files.push(folderEntry);
await this.addToProjFile(folderEntry);
return folderEntry;
}
/**
* Writes a file to disk, adds that file to the project, and writes it to disk
* @param relativeFilePath Relative path of the file
* @param contents Contents to be written to the new file
*/
public async addScriptItem(relativeFilePath: string, contents: string): Promise<ProjectEntry> {
const absoluteFilePath = path.join(this.projectFolderPath, relativeFilePath);
await fs.mkdir(path.dirname(absoluteFilePath), { recursive: true });
await fs.writeFile(absoluteFilePath, contents);
const fileEntry = this.createProjectEntry(relativeFilePath, EntryType.File);
this.files.push(fileEntry);
await this.addToProjFile(fileEntry);
return fileEntry;
}
private createProjectEntry(relativePath: string, entryType: EntryType): ProjectEntry {
return new ProjectEntry(vscode.Uri.file(path.join(this.projectFile, relativePath)), entryType);
return new ProjectEntry(vscode.Uri.file(path.join(this.projectFolderPath, relativePath)), relativePath, entryType);
}
private findOrCreateItemGroup(containedTag?: string): any {
let outputItemGroup = undefined;
// find any ItemGroup node that contains files; that's where we'll add
for (let i = 0; i < this.projFileXmlDoc.documentElement.getElementsByTagName(constants.ItemGroup).length; i++) {
const currentItemGroup = this.projFileXmlDoc.documentElement.getElementsByTagName(constants.ItemGroup)[i];
// if we're not hunting for a particular child type, or if we are and we find it, use the ItemGroup
if (!containedTag || currentItemGroup.getElementsByTagName(containedTag).length > 0) {
outputItemGroup = currentItemGroup;
break;
}
}
// if none already exist, make a new ItemGroup for it
if (!outputItemGroup) {
outputItemGroup = this.projFileXmlDoc.createElement(constants.ItemGroup);
this.projFileXmlDoc.documentElement.appendChild(outputItemGroup);
}
return outputItemGroup;
}
private addFileToProjFile(path: string) {
const newFileNode = this.projFileXmlDoc.createElement(constants.Build);
newFileNode.setAttribute(constants.Include, path);
this.findOrCreateItemGroup(constants.Build).appendChild(newFileNode);
}
private addFolderToProjFile(path: string) {
const newFolderNode = this.projFileXmlDoc.createElement(constants.Folder);
newFolderNode.setAttribute(constants.Include, path);
this.findOrCreateItemGroup(constants.Folder).appendChild(newFolderNode);
}
private async addToProjFile(entry: ProjectEntry) {
try {
switch (entry.type) {
case EntryType.File:
this.addFileToProjFile(entry.relativePath);
break;
case EntryType.Folder:
this.addFolderToProjFile(entry.relativePath);
}
await this.serializeToProjFile(this.projFileXmlDoc);
}
catch (err) {
vscode.window.showErrorMessage(getErrorMessage(err));
return;
}
}
private async serializeToProjFile(projFileContents: any) {
const xml = new xmldom.XMLSerializer().serializeToString(projFileContents); // TODO: how to get this to serialize with "pretty" formatting
await fs.writeFile(this.projectFilePath, xml);
}
}
@@ -72,16 +165,18 @@ export class ProjectEntry {
/**
* Absolute file system URI
*/
uri: vscode.Uri;
fsUri: vscode.Uri;
relativePath: string;
type: EntryType;
constructor(uri: vscode.Uri, type: EntryType) {
this.uri = uri;
constructor(uri: vscode.Uri, relativePath: string, type: EntryType) {
this.fsUri = uri;
this.relativePath = relativePath;
this.type = type;
}
public toString(): string {
return this.uri.path;
return this.fsUri.path;
}
}

View File

@@ -52,3 +52,5 @@ export class MessageTreeItem extends BaseProjectTreeItem {
return new vscode.TreeItem(this.message, vscode.TreeItemCollapsibleState.None);
}
}
export const SpacerTreeItem = new MessageTreeItem('');

View File

@@ -53,7 +53,7 @@ export class SqlConnectionDataSourceTreeItem extends DataSourceTreeItem {
public get treeItem(): vscode.TreeItem {
let item = new vscode.TreeItem(this.uri, vscode.TreeItemCollapsibleState.Collapsed);
item.label = `${this.dataSource.name} (${this.dataSource.friendlyName})`;
item.label = `${this.dataSource.name} (${this.dataSource.typeFriendlyName})`;
return item;
}

View File

@@ -50,7 +50,17 @@ export class FileNode extends BaseProjectTreeItem {
}
public get treeItem(): vscode.TreeItem {
return new vscode.TreeItem(this.uri, vscode.TreeItemCollapsibleState.None);
const treeItem = new vscode.TreeItem(this.uri, vscode.TreeItemCollapsibleState.None);
treeItem.command = {
title: 'Open file',
command: 'vscode.open',
arguments: [this.fileSystemUri]
};
treeItem.contextValue = 'File';
return treeItem;
}
}
@@ -58,7 +68,7 @@ export class FileNode extends BaseProjectTreeItem {
* Converts a full filesystem URI to a project-relative URI that's compatible with the project tree
*/
function fsPathToProjectUri(fileSystemUri: vscode.Uri, projectNode: ProjectRootTreeItem): vscode.Uri {
const projBaseDir = path.dirname(projectNode.project.projectFile);
const projBaseDir = path.dirname(projectNode.project.projectFilePath);
let localUri = '';
if (fileSystemUri.fsPath.startsWith(projBaseDir)) {

View File

@@ -20,7 +20,7 @@ export class ProjectRootTreeItem extends BaseProjectTreeItem {
project: Project;
constructor(project: Project) {
super(vscode.Uri.parse(path.basename(project.projectFile)), undefined);
super(vscode.Uri.parse(path.basename(project.projectFilePath)), undefined);
this.project = project;
this.dataSourceNode = new DataSourcesTreeItem(this);
@@ -57,16 +57,16 @@ export class ProjectRootTreeItem extends BaseProjectTreeItem {
switch (entry.type) {
case EntryType.File:
newNode = new fileTree.FileNode(entry.uri, parentNode);
newNode = new fileTree.FileNode(entry.fsUri, parentNode);
break;
case EntryType.Folder:
newNode = new fileTree.FolderNode(entry.uri, parentNode);
newNode = new fileTree.FolderNode(entry.fsUri, parentNode);
break;
default:
throw new Error(`Unknown EntryType: '${entry.type}'`);
}
parentNode.fileChildren[path.basename(entry.uri.path)] = newNode;
parentNode.fileChildren[path.basename(entry.fsUri.path)] = newNode;
}
}
@@ -74,7 +74,7 @@ export class ProjectRootTreeItem extends BaseProjectTreeItem {
* Gets the immediate parent tree node for an entry in a project file
*/
private getEntryParentNode(entry: ProjectEntry): fileTree.FolderNode | ProjectRootTreeItem {
const relativePathParts = utils.trimChars(utils.trimUri(vscode.Uri.file(this.project.projectFile), entry.uri), '/').split('/').slice(0, -1); // remove the last part because we only care about the parent
const relativePathParts = utils.trimChars(utils.trimUri(vscode.Uri.file(this.project.projectFilePath), entry.fsUri), '/').split('/').slice(0, -1); // remove the last part because we only care about the parent
if (relativePathParts.length === 0) {
return this; // if nothing left after trimming the entry itself, must been root
@@ -84,7 +84,7 @@ export class ProjectRootTreeItem extends BaseProjectTreeItem {
for (const part of relativePathParts) {
if (current.fileChildren[part] === undefined) {
current.fileChildren[part] = new fileTree.FolderNode(vscode.Uri.file(path.join(path.dirname(this.project.projectFile), part)), current);
current.fileChildren[part] = new fileTree.FolderNode(vscode.Uri.file(path.join(path.dirname(this.project.projectFilePath), part)), current);
}
if (current.fileChildren[part] instanceof fileTree.FileNode) {

View File

@@ -0,0 +1,43 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as constants from '../common/constants';
import * as templates from './templates';
export class ProjectScriptType {
type: string;
friendlyName: string;
templateScript: string;
constructor(type: string, friendlyName: string, templateScript: string) {
this.type = type;
this.friendlyName = friendlyName;
this.templateScript = templateScript;
}
}
export const script: string = 'script';
export const table: string = 'table';
export const view: string = 'view';
export const storedProcedure: string = 'storedProcedure';
export const folder: string = 'folder';
export const projectScriptTypes: ProjectScriptType[] = [
new ProjectScriptType(script, constants.scriptFriendlyName, templates.newSqlScriptTemplate),
new ProjectScriptType(table, constants.tableFriendlyName, templates.newSqlTableTemplate),
new ProjectScriptType(view, constants.viewFriendlyName, templates.newSqlViewTemplate),
new ProjectScriptType(storedProcedure, constants.storedProcedureFriendlyName, templates.newSqlStoredProcedureTemplate),
];
export const projectScriptTypeMap: Record<string, ProjectScriptType> = {};
for (const scriptType of projectScriptTypes) {
if (Object.keys(projectScriptTypeMap).find(s => s === scriptType.type.toLocaleLowerCase() || s === scriptType.friendlyName.toLocaleLowerCase())) {
throw new Error(`Script type map already contains ${scriptType.type} or its friendlyName.`);
}
projectScriptTypeMap[scriptType.type.toLocaleLowerCase()] = scriptType;
projectScriptTypeMap[scriptType.friendlyName.toLocaleLowerCase()] = scriptType;
}

View File

@@ -0,0 +1,30 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as path from 'path';
import { promises as fs } from 'fs';
// Project templates
export let newSqlProjectTemplate: string;
// Script templates
export let newSqlScriptTemplate: string;
export let newSqlTableTemplate: string;
export let newSqlViewTemplate: string;
export let newSqlStoredProcedureTemplate: string;
export async function loadTemplates(templateFolderPath: string) {
newSqlProjectTemplate = await loadTemplate(templateFolderPath, 'newSqlProjectTemplate.xml');
newSqlScriptTemplate = await loadTemplate(templateFolderPath, 'newTsqlScriptTemplate.sql');
newSqlTableTemplate = await loadTemplate(templateFolderPath, 'newTsqlTableTemplate.sql');
newSqlViewTemplate = await loadTemplate(templateFolderPath, 'newTsqlViewTemplate.sql');
newSqlStoredProcedureTemplate = await loadTemplate(templateFolderPath, 'newTsqlStoredProcedureTemplate.sql');
}
async function loadTemplate(templateFolderPath: string, fileName: string): Promise<string> {
return (await fs.readFile(path.join(templateFolderPath, fileName))).toString();
}

View File

@@ -0,0 +1,24 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as path from 'path';
import { promises as fs } from 'fs';
// Project baselines
export let newProjectFileBaseline: string;
export let openProjectFileBaseline: string;
export let openDataSourcesBaseline: string;
const baselineFolderPath = __dirname;
export async function loadBaselines() {
newProjectFileBaseline = await loadBaseline(baselineFolderPath, 'newSqlProjectBaseline.xml');
openProjectFileBaseline = await loadBaseline(baselineFolderPath, 'openSqlProjectBaseline.xml');
openDataSourcesBaseline = await loadBaseline(baselineFolderPath, 'openDataSourcesBaseline.json');
}
async function loadBaseline(baselineFolderPath: string, fileName: string): Promise<string> {
return (await fs.readFile(path.join(baselineFolderPath, fileName))).toString();
}

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="4.0">
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<Name>TestProjectName</Name>
<SchemaVersion>2.0</SchemaVersion>
<ProjectVersion>4.1</ProjectVersion>
<ProjectGuid>{BA5EBA11-C0DE-5EA7-ACED-BABB1E70A575}</ProjectGuid>
<DSP>Microsoft.Data.Tools.Schema.Sql.Sql130DatabaseSchemaProvider</DSP>
<OutputType>Database</OutputType>
<RootPath>
</RootPath>
<RootNamespace>TestProjectName</RootNamespace>
<AssemblyName>TestProjectName</AssemblyName>
<ModelCollation>1033, CI</ModelCollation>
<DefaultFileStructure>BySchemaAndSchemaType</DefaultFileStructure>
<DeployToDatabase>True</DeployToDatabase>
<TargetFrameworkVersion>v4.5</TargetFrameworkVersion>
<TargetLanguage>CS</TargetLanguage>
<AppDesignerFolder>Properties</AppDesignerFolder>
<SqlServerVerification>False</SqlServerVerification>
<IncludeCompositeObjects>True</IncludeCompositeObjects>
<TargetDatabaseSet>True</TargetDatabaseSet>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<OutputPath>bin\Release\</OutputPath>
<BuildScriptName>$(MSBuildProjectName).sql</BuildScriptName>
<TreatWarningsAsErrors>False</TreatWarningsAsErrors>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<DefineDebug>false</DefineDebug>
<DefineTrace>true</DefineTrace>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<OutputPath>bin\Debug\</OutputPath>
<BuildScriptName>$(MSBuildProjectName).sql</BuildScriptName>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<DefineDebug>true</DefineDebug>
<DefineTrace>true</DefineTrace>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup>
<VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">11.0</VisualStudioVersion>
<!-- Default to the v11.0 targets path if the targets file for the current VS version is not found -->
<SSDTExists Condition="Exists('$(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\SSDT\Microsoft.Data.Tools.Schema.SqlTasks.targets')">True</SSDTExists>
<VisualStudioVersion Condition="'$(SSDTExists)' == ''">11.0</VisualStudioVersion>
</PropertyGroup>
<Import Condition="'$(SQLDBExtensionsRefPath)' != ''" Project="$(SQLDBExtensionsRefPath)\Microsoft.Data.Tools.Schema.SqlTasks.targets" />
<Import Condition="'$(SQLDBExtensionsRefPath)' == ''" Project="$(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\SSDT\Microsoft.Data.Tools.Schema.SqlTasks.targets" />
<ItemGroup>
<Folder Include="Properties" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,21 @@
{
"version": "0.0.0",
"datasources" : [
{
"name": "Test Data Source 1",
"type": "sql_connection_string",
"version": "0.0.0",
"data": {
"connectionString": "Data Source=.;Initial Catalog=testDb;Integrated Security=True"
}
},
{
"name": "My Other Data Source",
"type": "sql_connection_string",
"version": "0.0.0",
"data": {
"connectionString": "Data Source=.;Initial Catalog=testDb2;Integrated Security=False"
}
}
]
}

View File

@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="4.0">
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<Name>TestProjectName</Name>
<SchemaVersion>2.0</SchemaVersion>
<ProjectVersion>4.1</ProjectVersion>
<ProjectGuid>{BA5EBA11-C0DE-5EA7-ACED-BABB1E70A575}</ProjectGuid>
<DSP>Microsoft.Data.Tools.Schema.Sql.Sql130DatabaseSchemaProvider</DSP>
<OutputType>Database</OutputType>
<RootPath>
</RootPath>
<RootNamespace>TestProjectName</RootNamespace>
<AssemblyName>TestProjectName</AssemblyName>
<ModelCollation>1033, CI</ModelCollation>
<DefaultFileStructure>BySchemaAndSchemaType</DefaultFileStructure>
<DeployToDatabase>True</DeployToDatabase>
<TargetFrameworkVersion>v4.5</TargetFrameworkVersion>
<TargetLanguage>CS</TargetLanguage>
<AppDesignerFolder>Properties</AppDesignerFolder>
<SqlServerVerification>False</SqlServerVerification>
<IncludeCompositeObjects>True</IncludeCompositeObjects>
<TargetDatabaseSet>True</TargetDatabaseSet>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<OutputPath>bin\Release\</OutputPath>
<BuildScriptName>$(MSBuildProjectName).sql</BuildScriptName>
<TreatWarningsAsErrors>False</TreatWarningsAsErrors>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<DefineDebug>false</DefineDebug>
<DefineTrace>true</DefineTrace>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<OutputPath>bin\Debug\</OutputPath>
<BuildScriptName>$(MSBuildProjectName).sql</BuildScriptName>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<DefineDebug>true</DefineDebug>
<DefineTrace>true</DefineTrace>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup>
<VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">11.0</VisualStudioVersion>
<!-- Default to the v11.0 targets path if the targets file for the current VS version is not found -->
<SSDTExists Condition="Exists('$(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\SSDT\Microsoft.Data.Tools.Schema.SqlTasks.targets')">True</SSDTExists>
<VisualStudioVersion Condition="'$(SSDTExists)' == ''">11.0</VisualStudioVersion>
</PropertyGroup>
<Import Condition="'$(SQLDBExtensionsRefPath)' != ''" Project="$(SQLDBExtensionsRefPath)\Microsoft.Data.Tools.Schema.SqlTasks.targets" />
<Import Condition="'$(SQLDBExtensionsRefPath)' == ''" Project="$(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\SSDT\Microsoft.Data.Tools.Schema.SqlTasks.targets" />
<ItemGroup>
<Folder Include="Properties" />
<Folder Include="Tables" />
<Folder Include="Views" />
<Folder Include="Views\Maintenance" />
</ItemGroup>
<ItemGroup>
<Build Include="Tables\Users.sql" />
<Build Include="Tables\Action History.sql" />
<Build Include="Views\Maintenance\Database Performance.sql" />
</ItemGroup>
<ItemGroup>
<Folder Include="Views\User" />
<Build Include="Views\User\Profile.sql" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,30 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as should from 'should';
import * as baselines from './baselines/baselines';
import * as testUtils from './testUtils';
import * as sql from '../models/dataSources/sqlConnectionStringSource';
import * as dataSources from '../models/dataSources/dataSources';
describe('Data Sources: DataSource operations', function (): void {
before(async function () : Promise<void> {
await baselines.loadBaselines();
});
it('Should read DataSources from datasource.json', async function (): Promise<void> {
const dataSourcePath = await testUtils.createTestDataSources(baselines.openDataSourcesBaseline);
const dataSourceList = await dataSources.load(dataSourcePath);
should(dataSourceList.length).equal(2);
should(dataSourceList[0].name).equal('Test Data Source 1');
should(dataSourceList[0].type).equal(sql.SqlConnectionDataSource.type);
should((dataSourceList[0] as sql.SqlConnectionDataSource).getSetting('Initial Catalog')).equal('testDb');
should(dataSourceList[1].name).equal('My Other Data Source');
should((dataSourceList[1] as sql.SqlConnectionDataSource).getSetting('Integrated Security')).equal('False');
});
});

View File

@@ -0,0 +1,48 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as path from 'path';
const testRunner = require('vscodetestcover');
const suite = 'Database Projects Extension Tests';
const mochaOptions: any = {
ui: 'bdd',
useColors: true,
timeout: 10000
};
// set relevant mocha options from the environment
if (process.env.ADS_TEST_GREP) {
mochaOptions.grep = process.env.ADS_TEST_GREP;
console.log(`setting options.grep to: ${mochaOptions.grep}`);
}
if (process.env.ADS_TEST_INVERT_GREP) {
mochaOptions.invert = parseInt(process.env.ADS_TEST_INVERT_GREP);
console.log(`setting options.invert to: ${mochaOptions.invert}`);
}
if (process.env.ADS_TEST_TIMEOUT) {
mochaOptions.timeout = parseInt(process.env.ADS_TEST_TIMEOUT);
console.log(`setting options.timeout to: ${mochaOptions.timeout}`);
}
if (process.env.ADS_TEST_RETRIES) {
mochaOptions.retries = parseInt(process.env.ADS_TEST_RETRIES);
console.log(`setting options.retries to: ${mochaOptions.retries}`);
}
if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY) {
mochaOptions.reporter = 'mocha-multi-reporters';
mochaOptions.reporterOptions = {
reporterEnabled: 'spec, mocha-junit-reporter',
mochaJunitReporterReporterOptions: {
testsuitesTitle: `${suite} ${process.platform}`,
mochaFile: path.join(process.env.BUILD_ARTIFACTSTAGINGDIRECTORY, `test-results/${process.platform}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`)
}
};
}
testRunner.configure(mochaOptions, { coverConfig: '../../coverConfig.json' });
export = testRunner;

View File

@@ -0,0 +1,57 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as should from 'should';
import * as path from 'path';
import * as baselines from './baselines/baselines';
import * as testUtils from './testUtils';
import { promises as fs } from 'fs';
import { Project, EntryType } from '../models/project';
let projFilePath: string;
describe('Project: sqlproj content operations', function (): void {
before(async function () : Promise<void> {
await baselines.loadBaselines();
});
beforeEach(async () => {
projFilePath = await testUtils.createTestSqlProj(baselines.openProjectFileBaseline);
});
it('Should read Project from sqlproj', async function (): Promise<void> {
const project: Project = new Project(projFilePath);
await project.readProjFile();
should(project.files.filter(f => f.type === EntryType.File).length).equal(4);
should(project.files.filter(f => f.type === EntryType.Folder).length).equal(5);
should(project.files.find(f => f.type === EntryType.Folder && f.relativePath === 'Views\\User')).not.equal(undefined); // mixed ItemGroup folder
should(project.files.find(f => f.type === EntryType.File && f.relativePath === 'Views\\User\\Profile.sql')).not.equal(undefined); // mixed ItemGroup file
});
it('Should add Folder and Build entries to sqlproj', async function (): Promise<void> {
const project: Project = new Project(projFilePath);
await project.readProjFile();
const folderPath = 'Stored Procedures';
const filePath = path.join(folderPath, 'Fake Stored Proc.sql');
const fileContents = 'SELECT \'This is not actually a stored procedure.\'';
await project.addFolderItem(folderPath);
await project.addScriptItem(filePath, fileContents);
const newProject = new Project(projFilePath);
await newProject.readProjFile();
should(newProject.files.find(f => f.type === EntryType.Folder && f.relativePath === folderPath)).not.equal(undefined);
should(newProject.files.find(f => f.type === EntryType.File && f.relativePath === filePath)).not.equal(undefined);
const newFileContents = (await fs.readFile(path.join(newProject.projectFolderPath, filePath))).toString();
should (newFileContents).equal(fileContents);
});
});

View File

@@ -0,0 +1,48 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as should from 'should';
import * as path from 'path';
import * as os from 'os';
import * as vscode from 'vscode';
import * as baselines from './baselines/baselines';
import * as templates from '../templates/templates';
import * as testUtils from './testUtils';
import { SqlDatabaseProjectTreeViewProvider } from '../controllers/databaseProjectTreeViewProvider';
import { ProjectsController } from '../controllers/projectController';
import { promises as fs } from 'fs';
describe('ProjectsController: project controller operations', function (): void {
before(async function () : Promise<void> {
await templates.loadTemplates(path.join(__dirname, '..', '..', 'resources', 'templates'));
await baselines.loadBaselines();
});
it('Should create new sqlproj file with correct values', async function (): Promise<void> {
const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider());
const projFileDir = path.join(os.tmpdir(), `TestProject_${new Date().getTime()}`);
const projFilePath = await projController.createNewProject('TestProjectName', vscode.Uri.file(projFileDir), 'BA5EBA11-C0DE-5EA7-ACED-BABB1E70A575');
let projFileText = (await fs.readFile(projFilePath)).toString();
should(projFileText).equal(baselines.newProjectFileBaseline);
});
it('Should load Project and associated DataSources', async function (): Promise<void> {
// setup test files
const folderPath = await testUtils.generateTestFolderPath();
const sqlProjPath = await testUtils.createTestSqlProj(baselines.openProjectFileBaseline, folderPath);
await testUtils.createTestDataSources(baselines.openDataSourcesBaseline, folderPath);
const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider());
const project = await projController.openProject(vscode.Uri.file(sqlProjPath));
should(project.files.length).equal(9); // detailed sqlproj tests in their own test file
should(project.dataSources.length).equal(2); // detailed datasources tests in their own test file
});
});

View File

@@ -0,0 +1,35 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as path from 'path';
import * as os from 'os';
import * as constants from '../common/constants';
import { promises as fs } from 'fs';
export async function createTestSqlProj(contents: string, folderPath?: string): Promise<string> {
return await createTestFile(contents, 'TestProject.sqlproj', folderPath);
}
export async function createTestDataSources(contents: string, folderPath?: string): Promise<string> {
return await createTestFile(contents, constants.dataSourcesFileName, folderPath);
}
export async function generateTestFolderPath(): Promise<string> {
const folderPath = path.join(os.tmpdir(), `TestProject_${new Date().getTime()}`);
await fs.mkdir(folderPath, { recursive: true });
return folderPath;
}
async function createTestFile(contents: string, fileName: string, folderPath?: string): Promise<string> {
folderPath = folderPath ?? await generateTestFolderPath();
const filePath = path.join(folderPath, fileName);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, contents);
return filePath;
}