Adds autorest-based SQL Project generation to SQL Database Projects extension (#17078)

* Initial changes

* checkpoint

* Constructing project with post deployment script

* Correcting to intentionally read from cached list of projects

* Adding activation event, fixing fresh workspace bug

* Convert netcoreTool and autorestHelper to share a helper class for streamed command

* Include npm package version to force update

* test checkpoint

* Unit tests

* Added contextual quickpicks for autorest dialogs

* Adding projectController test

* Added projectController test, some refactoring for testability

* Merge branch 'main' into benjin/autorest

* Fixing 'which' import

* PR feedback

* Fixing tests

* Adding additional information for when project provider tests fail

* Hopefully fixing failing tests (unable to repro locally)

* Adding Generate Project item to workspace menu

* PR feedback
This commit is contained in:
Benjin Dubishar
2021-09-16 20:38:40 -07:00
committed by GitHub
parent 0cf1abc7c2
commit 08e15bce99
18 changed files with 586 additions and 85 deletions

View File

@@ -8,6 +8,7 @@ import 'mocha';
import * as should from 'should';
import * as vscode from 'vscode';
import { ProjectProviderRegistry } from '../common/projectProviderRegistry';
import { prettyPrintProviders } from './testUtils';
export class MockTreeDataProvider implements vscode.TreeDataProvider<any>{
onDidChangeTreeData?: vscode.Event<any> | undefined;
@@ -38,6 +39,10 @@ export function createProjectProvider(projectTypes: IProjectType[], projectActio
}
suite('ProjectProviderRegistry Tests', function (): void {
this.beforeEach(() => {
ProjectProviderRegistry.clear();
});
test('register and unregister project providers', async () => {
const provider1 = createProjectProvider([
{
@@ -111,7 +116,7 @@ suite('ProjectProviderRegistry Tests', function (): void {
columns: [{ displayName: 'c1', width: 75, type: 'string' }],
data: [['d1']]
}]);
should.strictEqual(ProjectProviderRegistry.providers.length, 0, 'there should be no project provider at the beginning of the test');
should.strictEqual(ProjectProviderRegistry.providers.length, 0, `there should be no project provider at the beginning of the test, but found ${prettyPrintProviders()}`);
const disposable1 = ProjectProviderRegistry.registerProvider(provider1, 'test.testProvider');
let providerResult = ProjectProviderRegistry.getProviderByProjectExtension('testproj');
should.equal(providerResult, provider1, 'provider1 should be returned for testproj project type');
@@ -141,7 +146,7 @@ suite('ProjectProviderRegistry Tests', function (): void {
disposable2.dispose();
providerResult = ProjectProviderRegistry.getProviderByProjectExtension('sqlproj');
should.equal(providerResult, undefined, 'undefined should be returned for sqlproj project type after provider2 is disposed');
should.strictEqual(ProjectProviderRegistry.providers.length, 0, 'there should be no project provider after unregistering the providers');
should.strictEqual(ProjectProviderRegistry.providers.length, 0, `there should be no project provider after unregistering the providers, but found ${prettyPrintProviders()}`);
});
test('Clear the project provider registry', async () => {
@@ -163,10 +168,10 @@ suite('ProjectProviderRegistry Tests', function (): void {
columns: [{ displayName: 'c1', width: 75, type: 'string' }],
data: [['d1']]
}]);
should.strictEqual(ProjectProviderRegistry.providers.length, 0, 'there should be no project provider at the beginning of the test');
should.strictEqual(ProjectProviderRegistry.providers.length, 0, `there should be no project provider at the beginning of the test, but found ${prettyPrintProviders()}`);
ProjectProviderRegistry.registerProvider(provider, 'test.testProvider');
should.strictEqual(ProjectProviderRegistry.providers.length, 1, 'there should be only one project provider at this time');
should.strictEqual(ProjectProviderRegistry.providers.length, 1, `there should be only one project provider at this time, but found ${prettyPrintProviders()}`);
ProjectProviderRegistry.clear();
should.strictEqual(ProjectProviderRegistry.providers.length, 0, 'there should be no project provider after clearing the registry');
should.strictEqual(ProjectProviderRegistry.providers.length, 0, `there should be no project provider after clearing the registry, but found ${prettyPrintProviders()}`);
});
});

View File

@@ -7,6 +7,7 @@ import * as os from 'os';
import * as path from 'path';
import { IProjectType } from 'dataworkspace';
import { promises as fs } from 'fs';
import { ProjectProviderRegistry } from '../common/projectProviderRegistry';
export const testProjectType: IProjectType = {
id: 'tp1',
@@ -31,3 +32,7 @@ export async function createProjectFile(fileExt: string, contents?: string): Pro
export function generateUniqueProjectFilePath(fileExt: string): string {
return path.join(os.tmpdir(), `TestProject_${new Date().getTime()}.${fileExt}`);
}
export function prettyPrintProviders(): string {
return `${ProjectProviderRegistry.providers.map(p => `[${p.supportedProjectTypes.map(t => t.id).join(', ')}]`).join('; ')}`;
}

View File

@@ -45,7 +45,7 @@ suite('WorkspaceService', function (): void {
test('getProjectsInWorkspace', async () => {
// No workspace is loaded
let projects = await service.getProjectsInWorkspace(undefined, true);
should.strictEqual(projects.length, 0, 'no projects should be returned when no workspace is loaded');
should.strictEqual(projects.length, 0, `no projects should be returned when no workspace is loaded, but found ${projects.map(p => p.fsPath).join(', ')}`);
// No projects are present in the workspace file
const workspaceFoldersStub = sinon.stub(vscode.workspace, 'workspaceFolders').value([]);

View File

@@ -16,6 +16,7 @@
"onCommand:sqlDatabaseProjects.new",
"onCommand:sqlDatabaseProjects.open",
"onCommand:sqlDatabaseProjects.createProjectFromDatabase",
"onCommand:sqlDatabaseProjects.generateProjectFromOpenApiSpec",
"onCommand:sqlDatabaseProjects.addSqlBinding",
"workspaceContains:**/*.sqlproj",
"onView:dataworkspace.views.main"
@@ -44,6 +45,14 @@
"sqlDatabaseProjects.netCoreDoNotAsk": {
"type": "boolean",
"description": "%sqlDatabaseProjects.netCoreDoNotAsk%"
},
"sqlDatabaseProjects.nodejsDoNotAsk": {
"type": "boolean",
"description": "%sqlDatabaseProjects.nodejsDoNotAsk%"
},
"sqlDatabaseProjects.autorestSqlVersion": {
"type": "string",
"description": "%sqlDatabaseProjects.autorestSqlVersion%"
}
}
}
@@ -155,6 +164,11 @@
"title": "%sqlDatabaseProjects.changeTargetPlatform%",
"category": "%sqlDatabaseProjects.displayName%"
},
{
"command": "sqlDatabaseProjects.generateProjectFromOpenApiSpec",
"title": "%sqlDatabaseProjects.generateProjectFromOpenApiSpec%",
"category": "%sqlDatabaseProjects.displayName%"
},
{
"command": "sqlDatabaseProjects.addSqlBinding",
"title": "%sqlDatabaseProjects.addSqlBinding%"
@@ -165,7 +179,12 @@
{
"command": "sqlDatabaseProjects.createProjectFromDatabase",
"when": "view == dataworkspace.views.main",
"group": "1_currentWorkspace"
"group": "1_currentWorkspace@1"
},
{
"command": "sqlDatabaseProjects.generateProjectFromOpenApiSpec",
"when": "view == dataworkspace.views.main",
"group": "1_currentWorkspace@2"
}
],
"commandPalette": [
@@ -382,18 +401,22 @@
}
},
"dependencies": {
"@types/xml-formatter": "^1.1.0",
"@microsoft/ads-extension-telemetry": "^1.1.5",
"@types/which": "^2.0.1",
"@types/xml-formatter": "^1.1.0",
"fast-glob": "^3.1.0",
"fs-extra": "^5.0.0",
"jsonc-parser": "^2.3.1",
"promisify-child-process": "^3.1.1",
"semver": "^7.3.5",
"vscode-languageclient": "^5.3.0-next.1",
"vscode-nls": "^4.1.2",
"which": "^2.0.2",
"xml-formatter": "^2.1.0",
"xmldom": "^0.3.0"
},
"devDependencies": {
"@types/fs-extra": "^5.0.0",
"@types/mocha": "^5.2.5",
"@types/sinon": "^9.0.4",
"@types/xmldom": "^0.1.29",
@@ -405,8 +428,7 @@
"tslint": "^5.8.0",
"typemoq": "^2.1.0",
"typescript": "^2.6.1",
"vscodetestcover": "^1.1.0",
"@types/fs-extra": "^5.0.0"
"vscodetestcover": "^1.1.0"
},
"__metadata": {
"id": "70",

View File

@@ -30,10 +30,13 @@
"sqlDatabaseProjects.openContainingFolder": "Open Containing Folder",
"sqlDatabaseProjects.editProjectFile": "Edit .sqlproj File",
"sqlDatabaseProjects.changeTargetPlatform": "Change Target Platform",
"sqlDatabaseProjects.generateProjectFromOpenApiSpec": "Generate SQL Project from OpenAPI/Swagger spec",
"sqlDatabaseProjects.Settings": "Database Projects",
"sqlDatabaseProjects.netCoreInstallLocation": "Full path to .NET Core SDK on the machine.",
"sqlDatabaseProjects.netCoreDoNotAsk": "Whether to prompt the user to install .NET Core when not detected.",
"sqlDatabaseProjects.nodejsDoNotAsk": "Whether to prompt the user to install Node.js when not detected.",
"sqlDatabaseProjects.autorestSqlVersion": "Which version of Autorest.Sql to use from NPM. Latest will be used if not set.",
"sqlDatabaseProjects.welcome": "No database projects currently open.\n[New Project](command:sqlDatabaseProjects.new)\n[Open Project](command:sqlDatabaseProjects.open)\n[Create Project From Database](command:sqlDatabaseProjects.importDatabase)",
"sqlDatabaseProjects.addSqlBinding":"Add SQL Binding"

View File

@@ -13,6 +13,7 @@ const localize = nls.loadMessageBundle();
export const dataSourcesFileName = 'datasources.json';
export const sqlprojExtension = '.sqlproj';
export const sqlFileExtension = '.sql';
export const yamlFileExtension = '.yaml';
export const schemaCompareExtensionId = 'microsoft.schema-compare';
export const master = 'master';
export const masterDacpac = 'master.dacpac';
@@ -306,10 +307,12 @@ export const postDeployScriptFriendlyName = localize('postDeployScriptFriendlyNa
export const NetCoreInstallationConfirmation: string = localize('sqlDatabaseProjects.NetCoreInstallationConfirmation', "The .NET Core SDK cannot be located. Project build will not work. Please install .NET Core SDK version 3.1 or update the .NET Core SDK location in settings if already installed.");
export function NetCoreSupportedVersionInstallationConfirmation(installedVersion: string) { return localize('sqlDatabaseProjects.NetCoreSupportedVersionInstallationConfirmation', "Currently installed .NET Core SDK version is {0}, which is not supported. Project build will not work. Please install .NET Core SDK version 3.1 or update the .NET Core SDK supported version location in settings if already installed.", installedVersion); }
export const UpdateNetCoreLocation: string = localize('sqlDatabaseProjects.UpdateNetCoreLocation', "Update Location");
export const InstallNetCore: string = localize('sqlDatabaseProjects.InstallNetCore', "Install");
export const DoNotAskAgain: string = localize('sqlDatabaseProjects.doNotAskAgain', "Don't Ask Again");
export const projectsOutputChannel = localize('sqlDatabaseProjects.outputChannel', "Database Projects");
// Prompt buttons
export const Install: string = localize('sqlDatabaseProjects.Install', "Install");
export const DoNotAskAgain: string = localize('sqlDatabaseProjects.doNotAskAgain', "Don't Ask Again");
// SqlProj file XML names
export const ItemGroup = 'ItemGroup';
export const Build = 'Build';
@@ -408,6 +411,14 @@ export enum DatabaseProjectItemType {
dataSourceRoot = 'databaseProject.itemType.dataSourceRoot',
}
// AutoRest
export const autorestPostDeploymentScriptName = 'PostDeploymentScript.sql';
export const nodeButNotAutorestFound = localize('nodeButNotAutorestFound', "Autorest tool not found in system path, but found Node.js. Running via npx. Please execute 'npm install autorest -g' to install permanently.");
export const nodeNotFound = localize('nodeNotFound', "Neither autorest nor Node.js (npx) found in system path. Please install Node.js for autorest generation to work.");
export const selectSpecFile = localize('selectSpecFile', "Select OpenAPI/Swagger spec file");
export const generatingProjectFailed = localize('generatingProjectFailed', "Generating project via AutoRest failed");
export function multipleMostDeploymentScripts(count: number) { return localize('multipleMostDeploymentScripts', "Unexpected number of {0} files: {1}", autorestPostDeploymentScriptName, count); }
// System dbs
export const systemDbs = ['master', 'msdb', 'tempdb', 'model'];

View File

@@ -12,10 +12,11 @@ import * as glob from 'fast-glob';
import * as dataworkspace from 'dataworkspace';
import * as mssql from '../../../mssql';
import * as vscodeMssql from 'vscode-mssql';
import { promises as fs } from 'fs';
import { Project } from '../models/project';
import * as childProcess from 'child_process';
import * as fse from 'fs-extra';
import * as which from 'which';
import { promises as fs } from 'fs';
import { Project } from '../models/project';
export interface ValidationResult {
errorMessage: string;
@@ -489,6 +490,23 @@ export async function retry<T>(
return undefined;
}
/**
* Detects whether the specified command-line command is available on the current machine
*/
export async function detectCommandInstallation(command: string): Promise<boolean> {
try {
const found = await which(command);
if (found) {
return true;
}
} catch (err) {
console.log(getErrorMessage(err));
}
return false;
}
/**
* Gets all the projects of the specified extension in the folder
* @param folder

View File

@@ -57,6 +57,7 @@ export default class MainController implements vscode.Disposable {
vscode.commands.registerCommand('sqlDatabaseProjects.publish', async (node: WorkspaceTreeItem) => { this.projectsController.publishProject(node); });
vscode.commands.registerCommand('sqlDatabaseProjects.schemaCompare', async (node: WorkspaceTreeItem) => { return this.projectsController.schemaCompare(node); });
vscode.commands.registerCommand('sqlDatabaseProjects.createProjectFromDatabase', async (context: azdataType.IConnectionProfile | vscodeMssql.ITreeNodeInfo | undefined) => { return this.projectsController.createProjectFromDatabase(context); });
vscode.commands.registerCommand('sqlDatabaseProjects.generateProjectFromOpenApiSpec', async () => { return this.projectsController.generateProjectFromOpenApiSpec(); });
vscode.commands.registerCommand('sqlDatabaseProjects.newScript', async (node: WorkspaceTreeItem) => { return this.projectsController.addItemPromptFromNode(node, templates.script); });
vscode.commands.registerCommand('sqlDatabaseProjects.newPreDeploymentScript', async (node: WorkspaceTreeItem) => { return this.projectsController.addItemPromptFromNode(node, templates.preDeployScript); });

View File

@@ -24,7 +24,8 @@ import { IDeploySettings } from '../models/IDeploySettings';
import { BaseProjectTreeItem } from '../models/tree/baseTreeItem';
import { ProjectRootTreeItem } from '../models/tree/projectTreeItem';
import { ImportDataModel } from '../models/api/import';
import { NetCoreTool, DotNetCommandOptions, DotNetError } from '../tools/netcoreTool';
import { NetCoreTool, DotNetError } from '../tools/netcoreTool';
import { ShellCommandOptions } from '../tools/shellExecutionHelper';
import { BuildHelper } from '../tools/buildHelper';
import { readPublishProfile } from '../models/publishProfile/publishProfile';
import { AddDatabaseReferenceDialog } from '../dialogs/addDatabaseReferenceDialog';
@@ -38,6 +39,7 @@ import { launchPublishDatabaseQuickpick } from '../dialogs/publishDatabaseQuickp
import { launchPublishToDockerContainerQuickpick } from '../dialogs/deployDatabaseQuickpick';
import { DeployService } from '../models/deploy/deployService';
import { SqlTargetPlatform } from 'sqldbproj';
import { AutorestHelper } from '../tools/autorestHelper';
import { createNewProjectFromDatabaseWithQuickpick } from '../dialogs/createProjectFromDatabaseQuickpick';
import { addDatabaseReferenceQuickpick } from '../dialogs/addDatabaseReferenceQuickpick';
@@ -67,6 +69,7 @@ export class ProjectsController {
private buildInfo: DashboardData[] = [];
private publishInfo: PublishData[] = [];
private deployService: DeployService;
private autorestHelper: AutorestHelper;
projFileWatchers = new Map<string, vscode.FileSystemWatcher>();
@@ -74,6 +77,7 @@ export class ProjectsController {
this.netCoreTool = new NetCoreTool(outputChannel);
this.buildHelper = new BuildHelper();
this.deployService = new DeployService(outputChannel);
this.autorestHelper = new AutorestHelper(outputChannel);
}
public getDashboardPublishData(projectFile: string): (string | dataworkspace.IconCellValue)[][] {
@@ -215,7 +219,7 @@ export class ProjectsController {
// Check mssql extension for project dlls (tracking issue #10273)
await this.buildHelper.createBuildDirFolder();
const options: DotNetCommandOptions = {
const options: ShellCommandOptions = {
commandTitle: 'Build',
workingDirectory: project.projectFolderPath,
argument: this.buildHelper.constructBuildArguments(project.projectFilePath, this.buildHelper.extensionBuildDirPath)
@@ -827,6 +831,171 @@ export class ProjectsController {
return result;
}
public async selectAutorestSpecFile(): Promise<string | undefined> {
let quickpickSelection = await vscode.window.showQuickPick(
[constants.browseEllipsis],
{ title: constants.selectSpecFile, ignoreFocusOut: true });
if (!quickpickSelection) {
return;
}
const filters: { [name: string]: string[] } = {};
filters['OpenAPI/Swagger spec'] = ['yaml'];
let uris = await vscode.window.showOpenDialog({
canSelectFiles: true,
canSelectFolders: false,
canSelectMany: false,
openLabel: constants.selectString,
filters: filters,
title: constants.selectSpecFile
});
if (!uris) {
return;
}
return uris[0].fsPath;
}
/**
* @returns \{ newProjectFolder: 'C:\Source\MyProject',
* outputFolder: 'C:\Source',
* projectName: 'MyProject'}
*/
public async selectAutorestProjectLocation(specPath: string): Promise<{ newProjectFolder: string, outputFolder: string, projectName: string } | undefined> {
let valid = false;
let newProjectFolder: string = '';
let outputFolder: string = '';
let projectName: string = '';
let quickpickSelection = await vscode.window.showQuickPick(
[constants.browseEllipsis],
{ title: constants.selectProjectLocation, ignoreFocusOut: true });
if (!quickpickSelection) {
return;
}
while (!valid) {
const folders = await vscode.window.showOpenDialog({
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
openLabel: constants.selectString,
defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri,
title: constants.selectProjectLocation
});
if (!folders) {
return;
}
outputFolder = folders[0].fsPath;
projectName = path.basename(specPath, constants.yamlFileExtension);
newProjectFolder = path.join(outputFolder, projectName);
if (await utils.exists(newProjectFolder)) {
quickpickSelection = await vscode.window.showQuickPick(
[constants.browseEllipsis],
{ title: constants.folderAlreadyExistsChooseNewLocation(newProjectFolder), ignoreFocusOut: true });
if (!quickpickSelection) {
return;
}
} else {
valid = true;
}
}
await fs.mkdir(newProjectFolder);
return { newProjectFolder, outputFolder, projectName };
}
public async generateAutorestFiles(specPath: string, newProjectFolder: string): Promise<void> {
await this.autorestHelper.generateAutorestFiles(specPath, newProjectFolder);
}
public async openProjectInWorkspace(projectFilePath: string): Promise<void> {
const workspaceApi = utils.getDataWorkspaceExtensionApi();
await workspaceApi.validateWorkspace();
await workspaceApi.addProjectsToWorkspace([vscode.Uri.file(projectFilePath)]);
workspaceApi.showProjectsView();
}
public async generateProjectFromOpenApiSpec(): Promise<Project | undefined> {
try {
// 1. select spec file
const specPath: string | undefined = await this.selectAutorestSpecFile();
if (!specPath) {
return;
}
// 2. select location, make new folder
const projectInfo = await this.selectAutorestProjectLocation(specPath!);
if (!projectInfo) {
return;
}
// 3. run AutoRest to generate .sql files
await this.generateAutorestFiles(specPath, projectInfo.newProjectFolder);
// 4. create new SQL project
const newProjFilePath = await this.createNewProject({
newProjName: projectInfo.projectName,
folderUri: vscode.Uri.file(projectInfo.outputFolder),
projectTypeId: constants.emptySqlDatabaseProjectTypeId
});
const project = await Project.openProject(newProjFilePath);
// 5. add generated files to SQL project
let fileFolderList: vscode.Uri[] = await this.getSqlFileList(project.projectFolderPath);
await project.addToProject(fileFolderList.filter(f => !f.fsPath.endsWith(constants.autorestPostDeploymentScriptName))); // Add generated file structure to the project
const postDeploymentScript: vscode.Uri | undefined = this.findPostDeploymentScript(fileFolderList);
if (postDeploymentScript) {
await project.addScriptItem(path.relative(project.projectFolderPath, postDeploymentScript.fsPath), undefined, templates.postDeployScript);
}
// 6. add project to workspace and open
await this.openProjectInWorkspace(newProjFilePath);
return project;
} catch (err) {
void vscode.window.showErrorMessage(`${constants.generatingProjectFailed}: ${utils.getErrorMessage(err)}`);
return;
}
}
private findPostDeploymentScript(files: vscode.Uri[]): vscode.Uri | undefined {
const results = files.filter(f => f.fsPath.endsWith(constants.autorestPostDeploymentScriptName));
switch (results.length) {
case 0:
return undefined;
case 1:
return results[0];
default:
throw new Error(constants.multipleMostDeploymentScripts(results.length));
}
}
private async getSqlFileList(folder: string): Promise<vscode.Uri[]> {
const entries = await fs.readdir(folder, { withFileTypes: true });
const folders = entries.filter(dir => dir.isDirectory()).map(dir => path.join(folder, dir.name));
const files = entries.filter(file => !file.isDirectory() && path.extname(file.name) === '.sql').map(file => vscode.Uri.file(path.join(folder, file.name)));
for (const folder of folders) {
files.push(...await this.getSqlFileList(folder));
}
return files;
}
//#region Helper methods
public getPublishDialog(project: Project): PublishDatabaseDialog {

View File

@@ -0,0 +1,58 @@
/*---------------------------------------------------------------------------------------------
* 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 sinon from 'sinon';
import * as testUtils from './testUtils';
import * as utils from '../common/utils';
import * as path from 'path';
import { TestContext, createContext } from './testContext';
import { AutorestHelper } from '../tools/autorestHelper';
import { promises as fs } from 'fs';
let testContext: TestContext;
describe('Autorest tests', function (): void {
beforeEach(function (): void {
testContext = createContext();
});
afterEach(function (): void {
sinon.restore();
});
it('Should detect autorest', async function (): Promise<void> {
const autorestHelper = new AutorestHelper(testContext.outputChannel);
const executable = await autorestHelper.detectInstallation();
should(executable === 'autorest' || executable === 'npx autorest').equal(true, 'autorest command should be found in default path during unit tests');
});
it('Should run an autorest command successfully', async function (): Promise<void> {
const autorestHelper = new AutorestHelper(testContext.outputChannel);
const dummyFile = path.join(await testUtils.generateTestFolderPath(), 'testoutput.log');
sinon.stub(autorestHelper, 'constructAutorestCommand').returns(`${await autorestHelper.detectInstallation()} --version > ${dummyFile}`);
try {
await autorestHelper.generateAutorestFiles('fakespec.yaml', 'fakePath');
const text = (await fs.readFile(dummyFile)).toString().trim();
const expected = 'AutoRest code generation utility';
should(text.includes(expected)).equal(true, `Substring not found. Expected "${expected}" in "${text}"`);
} finally {
if (await utils.exists(dummyFile)) {
await fs.unlink(dummyFile);
}
}
});
it('Should construct a correct autorest command for project generation', async function (): Promise<void> {
const expectedOutput = 'autorest --use:autorest-sql-testing@latest --input-file="/some/path/test.yaml" --output-folder="/some/output/path" --clear-output-folder';
const autorestHelper = new AutorestHelper(testContext.outputChannel);
const constructedCommand = autorestHelper.constructAutorestCommand((await autorestHelper.detectInstallation())!, '/some/path/test.yaml', '/some/output/path');
// depending on whether the machine running the test has autorest installed or just node, the expected output may differ by just the prefix, hence matching against two options
should(constructedCommand === expectedOutput || constructedCommand === `npx ${expectedOutput}`).equal(true, `Constructed autorest command not formatting as expected:\nActual: ${constructedCommand}\nExpected: [npx ]${expectedOutput}`);
});
});

View File

@@ -21,7 +21,7 @@ import { SqlDatabaseProjectTreeViewProvider } from '../controllers/databaseProje
import { ProjectsController } from '../controllers/projectController';
import { promises as fs } from 'fs';
import { createContext, TestContext, mockDacFxResult, mockConnectionProfile } from './testContext';
import { Project, reservedProjectFolders, SystemDatabase, FileProjectEntry, SystemDatabaseReferenceProjectEntry } from '../models/project';
import { Project, reservedProjectFolders, SystemDatabase, FileProjectEntry, SystemDatabaseReferenceProjectEntry, EntryType } from '../models/project';
import { PublishDatabaseDialog } from '../dialogs/publishDatabaseDialog';
import { ProjectRootTreeItem } from '../models/tree/projectTreeItem';
import { FolderNode, FileNode } from '../models/tree/fileFolderTreeItem';
@@ -429,7 +429,7 @@ describe('ProjectsController', function (): void {
const proj = await testUtils.createTestProject(baselines.openProjectFileBaseline);
await projController.object.publishOrScriptProject(proj, { connectionUri: '', databaseName: '' , serverName: ''}, false);
await projController.object.publishOrScriptProject(proj, { connectionUri: '', databaseName: '', serverName: '' }, false);
should(builtDacpacPath).not.equal('', 'built dacpac path should be set');
should(publishedDacpacPath).not.equal('', 'published dacpac path should be set');
@@ -665,7 +665,7 @@ describe('ProjectsController', function (): void {
// add dacpac reference to something in the a folder outside of the project
await projController.addDatabaseReferenceCallback(project1, {
databaseName: <string>this.databaseNameTextbox?.value,
dacpacFileLocation: vscode.Uri.file(path.join(path.dirname(projFilePath), '..','someFolder', 'outsideFolderTest.dacpac')),
dacpacFileLocation: vscode.Uri.file(path.join(path.dirname(projFilePath), '..', 'someFolder', 'outsideFolderTest.dacpac')),
suppressMissingDependenciesErrors: false
},
{ treeDataProvider: new SqlDatabaseProjectTreeViewProvider(), element: undefined });
@@ -678,6 +678,49 @@ describe('ProjectsController', function (): void {
should(projFileText).containEql('..\\someFolder\\outsideFolderTest.dacpac');
});
});
describe('AutoRest generation', function (): void {
it('Should create project from autorest-generated files', async function (): Promise<void> {
const parentFolder = await testUtils.generateTestFolderPath();
await testUtils.createDummyFileStructure();
const specName = 'DummySpec.yaml';
const newProjFolder = path.join(parentFolder, path.basename(specName, '.yaml'));
let fileList: vscode.Uri[] = [];
const projController = TypeMoq.Mock.ofType(ProjectsController);
projController.callBase = true;
projController.setup(x => x.selectAutorestSpecFile()).returns(async () => specName);
projController.setup(x => x.selectAutorestProjectLocation(TypeMoq.It.isAny())).returns(async () => {
await fs.mkdir(newProjFolder);
return {
newProjectFolder: newProjFolder,
outputFolder: parentFolder,
projectName: path.basename(specName, '.yaml')
};
});
projController.setup(x => x.generateAutorestFiles(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(async () => {
await testUtils.createDummyFileStructure(true, fileList, newProjFolder);
await testUtils.createTestFile('SELECT \'This is a post-deployment script\'', constants.autorestPostDeploymentScriptName, newProjFolder);
});
projController.setup(x => x.openProjectInWorkspace(TypeMoq.It.isAny())).returns(async () => { });
const project = (await projController.object.generateProjectFromOpenApiSpec())!;
should(project.postDeployScripts.length).equal(1, `Expected 1 post-deployment script, got ${project?.postDeployScripts.length}`);
const actual = path.basename(project.postDeployScripts[0].fsUri.fsPath);
should(actual).equal(constants.autorestPostDeploymentScriptName, `Unexpected post-deployment script name: ${actual}, expected ${constants.autorestPostDeploymentScriptName}`);
const expectedScripts = fileList.filter(f => path.extname(f.fsPath) === '.sql');
should(project.files.filter(f => f.type === EntryType.File).length).equal(expectedScripts.length, 'Unexpected number of scripts in project');
const expectedFolders = fileList.filter(f => path.extname(f.fsPath) === '' && f.fsPath.toUpperCase() !== newProjFolder.toUpperCase());
should(project.files.filter(f => f.type === EntryType.Folder).length).equal(expectedFolders.length, 'Unexpected number of folders in project');
});
});
});
async function setupDeleteExcludeTest(proj: Project): Promise<[FileProjectEntry, ProjectRootTreeItem, FileProjectEntry, FileProjectEntry, FileProjectEntry]> {

View File

@@ -42,7 +42,7 @@ export async function createTestDataSources(contents: string, folderPath?: strin
}
export async function generateTestFolderPath(): Promise<string> {
const folderPath = path.join(os.tmpdir(), `TestRun_${new Date().getTime()}`);
const folderPath = path.join(os.tmpdir(), 'ADS_Tests', `TestRun_${new Date().getTime()}`);
await fs.mkdir(folderPath, { recursive: true });
return folderPath;

View File

@@ -7,7 +7,7 @@ import * as should from 'should';
import * as path from 'path';
import * as os from 'os';
import { createDummyFileStructure } from './testUtils';
import { exists, trimUri, removeSqlCmdVariableFormatting, formatSqlCmdVariable, isValidSqlCmdVariableName, timeConversion, validateSqlServerPortNumber, isEmptyString } from '../common/utils';
import { exists, trimUri, removeSqlCmdVariableFormatting, formatSqlCmdVariable, isValidSqlCmdVariableName, timeConversion, validateSqlServerPortNumber, isEmptyString, detectCommandInstallation } from '../common/utils';
import { Uri } from 'vscode';
describe('Tests to verify utils functions', function (): void {
@@ -105,5 +105,10 @@ describe('Tests to verify utils functions', function (): void {
should(isEmptyString(undefined)).equals(true);
should(isEmptyString('65536')).equals(false);
});
it('Should correctly detect present commands', async () => {
should(await detectCommandInstallation('node')).equal(true, '"node" should have been detected.');
should(await detectCommandInstallation('bogusFakeCommand')).equal(false, '"bogusFakeCommand" should have been detected.');
});
});

View File

@@ -0,0 +1,106 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { DoNotAskAgain, Install, nodeButNotAutorestFound, nodeNotFound } from '../common/constants';
import * as utils from '../common/utils';
import * as semver from 'semver';
import { DBProjectConfigurationKey } from './netcoreTool';
import { ShellExecutionHelper } from './shellExecutionHelper';
const autorestPackageName = 'autorest-sql-testing'; // name of AutoRest.Sql package on npm
const nodejsDoNotAskAgainKey: string = 'nodejsDoNotAsk';
const autorestSqlVersionKey: string = 'autorestSqlVersion';
/**
* Helper class for dealing with Autorest generation and detection
*/
export class AutorestHelper extends ShellExecutionHelper {
constructor(_outputChannel: vscode.OutputChannel) {
super(_outputChannel);
}
/**
* Checks the workspace configuration to for an AutoRest.Sql override, otherwise latest will be used from NPM
*/
public get autorestSqlPackageVersion(): string {
let configVal: string | undefined = vscode.workspace.getConfiguration(DBProjectConfigurationKey)[autorestSqlVersionKey];
if (configVal && semver.valid(configVal.trim())) {
return configVal.trim();
} else {
return 'latest';
}
}
/**
* @returns the beginning of the command to execute autorest; 'autorest' if available, 'npx autorest' if module not installed, or undefined if neither
*/
public async detectInstallation(): Promise<string | undefined> {
const autorestCommand = 'autorest';
const npxCommand = 'npx';
if (await utils.detectCommandInstallation(autorestCommand)) {
return autorestCommand;
}
if (await utils.detectCommandInstallation(npxCommand)) {
this._outputChannel.appendLine(nodeButNotAutorestFound);
return `${npxCommand} ${autorestCommand}`;
}
return undefined;
}
/**
* Calls autorest to generate files from the spec, piping standard and error output to the host console
* @param specPath path to the OpenAPI spec file
* @param outputFolder folder in which to generate the .sql script files
* @returns console output from autorest execution
*/
public async generateAutorestFiles(specPath: string, outputFolder: string): Promise<string | undefined> {
const commandExecutable = await this.detectInstallation();
if (commandExecutable === undefined) {
// unable to find autorest or npx
if (vscode.workspace.getConfiguration(DBProjectConfigurationKey)[nodejsDoNotAskAgainKey] !== true) {
this._outputChannel.appendLine(nodeNotFound);
return; // user doesn't want to be prompted about installing it
}
// prompt user to install Node.js
const result = await vscode.window.showErrorMessage(nodeNotFound, DoNotAskAgain, Install);
if (result === Install) {
//open install link
const nodejsInstallationUrl = 'https://nodejs.dev/download';
await vscode.env.openExternal(vscode.Uri.parse(nodejsInstallationUrl));
} else if (result === DoNotAskAgain) {
const config = vscode.workspace.getConfiguration(DBProjectConfigurationKey);
await config.update(nodejsDoNotAskAgainKey, true, vscode.ConfigurationTarget.Global);
}
return;
}
const command = this.constructAutorestCommand(commandExecutable, specPath, outputFolder);
const output = await this.runStreamedCommand(command, this._outputChannel);
return output;
}
/**
*
* @param executable either "autorest" or "npx autorest", depending on whether autorest is already present in the global cache
* @param specPath path to the OpenAPI spec
* @param outputFolder folder in which to generate the files
* @returns composed command to be executed
*/
public constructAutorestCommand(executable: string, specPath: string, outputFolder: string): string {
// TODO: should --clear-output-folder be included? We should always be writing to a folder created just for this, but potentially risky
return `${executable} --use:${autorestPackageName}@${this.autorestSqlPackageVersion} --input-file="${specPath}" --output-folder="${outputFolder}" --clear-output-folder`;
}
}

View File

@@ -7,13 +7,13 @@ import * as child_process from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as cp from 'promisify-child-process';
import * as semver from 'semver';
import { isNullOrUndefined } from 'util';
import * as vscode from 'vscode';
import * as nls from 'vscode-nls';
import { DoNotAskAgain, InstallNetCore, NetCoreInstallationConfirmation, NetCoreSupportedVersionInstallationConfirmation, UpdateNetCoreLocation } from '../common/constants';
import { DoNotAskAgain, Install, NetCoreInstallationConfirmation, NetCoreSupportedVersionInstallationConfirmation, UpdateNetCoreLocation } from '../common/constants';
import * as utils from '../common/utils';
import { ShellCommandOptions, ShellExecutionHelper } from './shellExecutionHelper';
const localize = nls.loadMessageBundle();
export const DBProjectConfigurationKey: string = 'sqlDatabaseProjects';
@@ -33,13 +33,7 @@ export const enum netCoreInstallState {
const dotnet = os.platform() === 'win32' ? 'dotnet.exe' : 'dotnet';
export interface DotNetCommandOptions {
workingDirectory?: string;
additionalEnvironmentVariables?: NodeJS.ProcessEnv;
commandTitle?: string;
argument?: string;
}
export class NetCoreTool {
export class NetCoreTool extends ShellExecutionHelper {
private osPlatform: string = os.platform();
private netCoreSdkInstalledVersion: string | undefined;
@@ -61,22 +55,22 @@ export class NetCoreTool {
return true;
}
constructor(private _outputChannel: vscode.OutputChannel) {
constructor(_outputChannel: vscode.OutputChannel) {
super(_outputChannel);
}
public async showInstallDialog(): Promise<void> {
let result;
if (this.netCoreInstallState === netCoreInstallState.netCoreNotPresent) {
result = await vscode.window.showErrorMessage(NetCoreInstallationConfirmation, UpdateNetCoreLocation, InstallNetCore, DoNotAskAgain);
result = await vscode.window.showErrorMessage(NetCoreInstallationConfirmation, UpdateNetCoreLocation, Install, DoNotAskAgain);
} else {
result = await vscode.window.showErrorMessage(NetCoreSupportedVersionInstallationConfirmation(this.netCoreSdkInstalledVersion!), UpdateNetCoreLocation, InstallNetCore, DoNotAskAgain);
result = await vscode.window.showErrorMessage(NetCoreSupportedVersionInstallationConfirmation(this.netCoreSdkInstalledVersion!), UpdateNetCoreLocation, Install, DoNotAskAgain);
}
if (result === UpdateNetCoreLocation) {
//open settings
await vscode.commands.executeCommand('workbench.action.openGlobalSettings');
} else if (result === InstallNetCore) {
} else if (result === Install) {
//open install link
const dotnetcoreURL = 'https://dotnet.microsoft.com/download/dotnet-core/3.1';
await vscode.env.openExternal(vscode.Uri.parse(dotnetcoreURL));
@@ -183,7 +177,7 @@ export class NetCoreTool {
}
}
public async runDotnetCommand(options: DotNetCommandOptions): Promise<string> {
public async runDotnetCommand(options: ShellCommandOptions): Promise<string> {
if (options && options.commandTitle !== undefined && options.commandTitle !== null) {
this._outputChannel.appendLine(`\t[ ${options.commandTitle} ]`);
}
@@ -206,53 +200,6 @@ export class NetCoreTool {
throw error;
}
}
// spawns the dotnet command with arguments and redirects the error and output to ADS output channel
public async runStreamedCommand(command: string, outputChannel: vscode.OutputChannel, options?: DotNetCommandOptions): Promise<string> {
const stdoutData: string[] = [];
outputChannel.appendLine(` > ${command}`);
const spawnOptions = {
cwd: options && options.workingDirectory,
env: Object.assign({}, process.env, options && options.additionalEnvironmentVariables),
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024, // 10 Mb of output can be captured.
shell: true,
detached: false,
windowsHide: true
};
const child = cp.spawn(command, [], spawnOptions);
outputChannel.show();
// Add listeners to print stdout and stderr and exit code
void child.on('exit', (code: number | null, signal: string | null) => {
if (code !== null) {
outputChannel.appendLine(localize('sqlDatabaseProjects.RunStreamedCommand.ExitedWithCode', " >>> {0} … exited with code: {1}", command, code));
} else {
outputChannel.appendLine(localize('sqlDatabaseProjects.RunStreamedCommand.ExitedWithSignal', " >>> {0} … exited with signal: {1}", command, signal));
}
});
child.stdout!.on('data', (data: string | Buffer) => {
stdoutData.push(data.toString());
this.outputDataChunk(data, outputChannel, localize('sqlDatabaseProjects.RunCommand.stdout', " stdout: "));
});
child.stderr!.on('data', (data: string | Buffer) => {
this.outputDataChunk(data, outputChannel, localize('sqlDatabaseProjects.RunCommand.stderr', " stderr: "));
});
await child;
return stdoutData.join('');
}
private outputDataChunk(data: string | Buffer, outputChannel: vscode.OutputChannel, header: string): void {
data.toString().split(/\r?\n/)
.forEach(line => {
outputChannel.appendLine(header + line);
});
}
}
export class DotNetError extends Error {

View File

@@ -6,7 +6,8 @@ import * as vscode from 'vscode';
import * as utils from '../common/utils';
import * as azureFunctionsUtils from '../common/azureFunctionsUtils';
import * as constants from '../common/constants';
import { DotNetCommandOptions, NetCoreTool } from './netcoreTool';
import { NetCoreTool } from './netcoreTool';
import { ShellCommandOptions } from './shellExecutionHelper';
export class PackageHelper {
private netCoreTool: NetCoreTool;
@@ -40,7 +41,7 @@ export class PackageHelper {
* @param packageVersion optional version of package. If none, latest will be pulled in
*/
public async addPackage(project: string, packageName: string, packageVersion?: string): Promise<void> {
const addOptions: DotNetCommandOptions = {
const addOptions: ShellCommandOptions = {
commandTitle: constants.addPackage,
argument: this.constructAddPackageArguments(project, packageName, packageVersion)
};

View File

@@ -0,0 +1,71 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as cp from 'promisify-child-process';
import * as vscode from 'vscode';
import * as nls from 'vscode-nls';
const localize = nls.loadMessageBundle();
export interface ShellCommandOptions {
workingDirectory?: string;
additionalEnvironmentVariables?: NodeJS.ProcessEnv;
commandTitle?: string;
argument?: string;
}
export class ShellExecutionHelper {
constructor(protected _outputChannel: vscode.OutputChannel) {
}
/**
* spawns the shell command with arguments and redirects the error and output to ADS output channel
*/
public async runStreamedCommand(command: string, outputChannel: vscode.OutputChannel, options?: ShellCommandOptions): Promise<string> {
const stdoutData: string[] = [];
outputChannel.appendLine(` > ${command}`);
const spawnOptions = {
cwd: options && options.workingDirectory,
env: Object.assign({}, process.env, options && options.additionalEnvironmentVariables),
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024, // 10 Mb of output can be captured.
shell: true,
detached: false,
windowsHide: true
};
const child = cp.spawn(command, [], spawnOptions);
outputChannel.show();
// Add listeners to print stdout and stderr and exit code
void child.on('exit', (code: number | null, signal: string | null) => {
if (code !== null) {
outputChannel.appendLine(localize('sqlDatabaseProjects.RunStreamedCommand.ExitedWithCode', " >>> {0} … exited with code: {1}", command, code));
} else {
outputChannel.appendLine(localize('sqlDatabaseProjects.RunStreamedCommand.ExitedWithSignal', " >>> {0} … exited with signal: {1}", command, signal));
}
});
child.stdout!.on('data', (data: string | Buffer) => {
stdoutData.push(data.toString());
this.outputDataChunk(data, outputChannel, localize('sqlDatabaseProjects.RunCommand.stdout', " stdout: "));
});
child.stderr!.on('data', (data: string | Buffer) => {
this.outputDataChunk(data, outputChannel, localize('sqlDatabaseProjects.RunCommand.stderr', " stderr: "));
});
await child;
return stdoutData.join('');
}
private outputDataChunk(data: string | Buffer, outputChannel: vscode.OutputChannel, header: string): void {
data.toString().split(/\r?\n/)
.forEach(line => {
outputChannel.appendLine(header + line);
});
}
}

View File

@@ -298,6 +298,11 @@
resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.1.tgz#681df970358c82836b42f989188d133e218c458e"
integrity sha512-yYezQwGWty8ziyYLdZjwxyMb0CZR49h8JALHGrxjQHWlqGgc8kLdHEgWrgL0uZ29DMvEVBDnHU2Wg36zKSIUtA==
"@types/which@^2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@types/which/-/which-2.0.1.tgz#27ecd67f915b7c3d6ba552135bb1eecd66e63501"
integrity sha512-Jjakcv8Roqtio6w1gr0D7y6twbhx6gGgFGF5BLwajPpnOIOxFkakFhCq+LmyyeAz7BX6ULrjBOxdKaCDy+4+dQ==
"@types/xml-formatter@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@types/xml-formatter/-/xml-formatter-1.1.0.tgz#f7cde70ec33d7b044029b6b6c2f6e69d270ced63"
@@ -711,6 +716,11 @@ isarray@0.0.1:
resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
isexe@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=
istanbul-lib-coverage@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz#675f0ab69503fad4b1d849f736baaca803344f49"
@@ -821,6 +831,13 @@ lodash@^4.16.4, lodash@^4.17.13, lodash@^4.17.4:
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
lru-cache@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==
dependencies:
yallist "^4.0.0"
make-dir@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5"
@@ -1036,6 +1053,13 @@ semver@^6.0.0, semver@^6.3.0:
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
semver@^7.3.5:
version "7.3.5"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7"
integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==
dependencies:
lru-cache "^6.0.0"
shimmer@^1.1.0, shimmer@^1.2.0:
version "1.2.1"
resolved "https://registry.yarnpkg.com/shimmer/-/shimmer-1.2.1.tgz#610859f7de327b587efebf501fb43117f9aff337"
@@ -1271,6 +1295,13 @@ vscodetestcover@^1.1.0:
istanbul-reports "^3.0.0"
mocha "^5.2.0"
which@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==
dependencies:
isexe "^2.0.0"
wrappy@1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
@@ -1297,3 +1328,8 @@ xmldom@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.3.0.tgz#e625457f4300b5df9c2e1ecb776147ece47f3e5a"
integrity sha512-z9s6k3wxE+aZHgXYxSTpGDo7BYOUfJsIRyoZiX6HTjwpwfS2wpQBQKa2fD+ShLyPkqDYo5ud7KitmLZ2Cd6r0g==
yallist@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==