diff --git a/extension.bundle.ts b/extension.bundle.ts index 76e7c8dc3..391d5f388 100644 --- a/extension.bundle.ts +++ b/extension.bundle.ts @@ -15,8 +15,10 @@ // The tests should import '../extension.bundle'. At design-time they live in tests/ and so will pick up this file (extension.bundle.ts). // At runtime the tests live in dist/tests and will therefore pick up the main webpack bundle at dist/extension.bundle.js. export * from 'vscode-azureextensionui'; +export { tryGetSelection } from './src/commands/openYAMLConfigFile'; // Export activate/deactivate for main.js export { activateInternal, deactivateInternal } from './src/extension'; export * from './src/extensionVariables'; +export { BuildConfig } from './src/tree/localProject/ConfigGroupTreeItem'; // NOTE: The auto-fix action "source.organizeImports" does weird things with this file, but there doesn't seem to be a way to disable it on a per-file basis so we'll just let it happen diff --git a/src/commands/openYAMLConfigFile.ts b/src/commands/openYAMLConfigFile.ts index 81d0ad197..eb5be266f 100644 --- a/src/commands/openYAMLConfigFile.ts +++ b/src/commands/openYAMLConfigFile.ts @@ -4,8 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { basename } from 'path'; -import { Position, Range, TextDocument, window, workspace } from "vscode"; +import { Position, Range, TextDocument, window, workspace } from 'vscode'; import { IActionContext, IAzureQuickPickItem } from "vscode-azureextensionui"; +import { CST, Document, parseDocument } from 'yaml'; +import { Pair, Scalar, YAMLMap, YAMLSeq } from 'yaml/types'; import { ext } from "../extensionVariables"; import { EnvironmentTreeItem } from "../tree/EnvironmentTreeItem"; import { BuildConfig, GitHubConfigGroupTreeItem } from '../tree/localProject/ConfigGroupTreeItem'; @@ -41,18 +43,70 @@ export async function openYAMLConfigFile(context: IActionContext, node?: StaticW } const configDocument: TextDocument = await workspace.openTextDocument(yamlFilePath); - const selection: Range | undefined = buildConfigToSelect ? await getSelection(configDocument, buildConfigToSelect) : undefined; + const selection: Range | undefined = buildConfigToSelect ? await tryGetSelection(configDocument, buildConfigToSelect) : undefined; await window.showTextDocument(configDocument, { selection }); } -async function getSelection(configDocument: TextDocument, buildConfigToSelect: BuildConfig): Promise { - const configRegex: RegExp = new RegExp(`${buildConfigToSelect}:`); +export async function tryGetSelection(configDocument: TextDocument, buildConfigToSelect: BuildConfig): Promise { + const configDocumentText: string = configDocument.getText(); + const buildConfigRegex: RegExp = new RegExp(`${buildConfigToSelect}:`, 'g'); + const buildConfigMatches: RegExpMatchArray | null = configDocumentText.match(buildConfigRegex); - let offset: number = configDocument.getText().search(configRegex); - // Shift the offset to the beginning of the build config's value - offset += `${buildConfigToSelect}: `.length; + if (buildConfigMatches && buildConfigMatches.length > 1) { + void ext.ui.showWarningMessage(localize('foundMultipleBuildConfigs', 'Multiple "{0}" build configurations were found in "{1}".', buildConfigToSelect, basename(configDocument.uri.fsPath))); + return undefined; + } + + try { + type YamlNode = YAMLMap | YAMLSeq | Pair | Scalar | undefined | null; + const yamlNodes: YamlNode[] = []; + const parsedYaml: Document.Parsed = parseDocument(configDocumentText, { keepCstNodes: true }); + let yamlNode: YamlNode = parsedYaml.contents; + + while (yamlNode) { + if ('key' in yamlNode && (yamlNode.key).value === buildConfigToSelect && 'value' in yamlNode) { + const cstNode: CST.Node | undefined = (yamlNode.value)?.cstNode; + const range = cstNode?.rangeAsLinePos; + + if (range && range.end) { + // Range isn't zero-indexed by default + range.start.line--; + range.start.col--; + range.end.line--; + range.end.col--; + + if (cstNode?.comment) { + // The end range includes the length of the comment + range.end.col -= cstNode.comment.length + 1; + + const lineText: string = (configDocument.lineAt(range.start.line)).text; + + // Don't include the comment character + if (lineText[range.end.col] === '#') { + range.end.col--; + } + + // Don't include any horizontal whitespace between the end of the YAML value and the comment + while (/[ \t]/.test(lineText[range.end.col - 1])) { + range.end.col--; + } + } + + const startPosition: Position = new Position(range.start.line, range.start.col); + const endPosition: Position = new Position(range.end.line, range.end.col); + return configDocument.validateRange(new Range(startPosition, endPosition)); + } + } else if ('items' in yamlNode) { + yamlNodes.push(...yamlNode.items) + } else if ('value' in yamlNode && typeof yamlNode.value === 'object') { + yamlNodes.push(yamlNode.value) + } + + yamlNode = yamlNodes.pop(); + } + } catch { + // Ignore errors + } - const position: Position = configDocument.positionAt(offset); - const configValueRegex: RegExp = /['"].*['"]/; - return configDocument.getWordRangeAtPosition(position, configValueRegex); + return undefined; } diff --git a/test/global.test.ts b/test/global.test.ts index 226aef3dd..91a69f91e 100644 --- a/test/global.test.ts +++ b/test/global.test.ts @@ -32,10 +32,3 @@ suiteSetup(async function (this: IHookCallbackContext): Promise { longRunningTestsEnabled = !/^(false|0)?$/i.test(process.env.ENABLE_LONG_RUNNING_TESTS || ''); }); - -suite('suite1', () => { - test('test1', () => { - // suiteSetup only runs if a suite/test exists, so added a placeholder test here so we can at least verify the extension can activate - // once actual tests exist, we can remove this - }); -}); diff --git a/test/selectBuildConfigs.test.ts b/test/selectBuildConfigs.test.ts new file mode 100644 index 000000000..23c6ab53b --- /dev/null +++ b/test/selectBuildConfigs.test.ts @@ -0,0 +1,172 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { Position, Range, TextDocument, TextDocumentContentProvider, Uri, workspace } from 'vscode'; +import { BuildConfig, tryGetSelection } from "../extension.bundle"; + +interface ISelectBuildConfigTestCase { + workflowIndex: number; + buildConfig: BuildConfig; + expectedSelection: { + line: number; + startChar: number; + endChar: number; + } | undefined; +} + +suite('Select Build Configurations in GitHub Workflow Files', () => { + const testCases: ISelectBuildConfigTestCase[] = [ + { workflowIndex: 0, buildConfig: 'api_location', expectedSelection: { line: 7, startChar: 24, endChar: 26 } }, + { workflowIndex: 0, buildConfig: 'app_location', expectedSelection: { line: 6, startChar: 24, endChar: 27 } }, + { workflowIndex: 0, buildConfig: 'output_location', expectedSelection: { line: 8, startChar: 27, endChar: 29 } }, + { workflowIndex: 0, buildConfig: 'app_artifact_location', expectedSelection: undefined }, + + { workflowIndex: 1, buildConfig: 'api_location', expectedSelection: { line: 7, startChar: 24, endChar: 38 } }, + { workflowIndex: 1, buildConfig: 'app_location', expectedSelection: { line: 6, startChar: 24, endChar: 38 } }, + { workflowIndex: 1, buildConfig: 'output_location', expectedSelection: undefined }, + { workflowIndex: 1, buildConfig: 'app_artifact_location', expectedSelection: { line: 8, startChar: 33, endChar: 54 }}, + + { workflowIndex: 2, buildConfig: 'api_location', expectedSelection: { line: 7, startChar: 24, endChar: 50 } }, + { workflowIndex: 2, buildConfig: 'app_location', expectedSelection: { line: 6, startChar: 24, endChar: 30 } }, + { workflowIndex: 2, buildConfig: 'output_location', expectedSelection: { line: 8, startChar: 27, endChar: 40 } }, + { workflowIndex: 2, buildConfig: 'app_artifact_location', expectedSelection: undefined }, + + { workflowIndex: 3, buildConfig: 'api_location', expectedSelection: undefined }, + { workflowIndex: 3, buildConfig: 'app_location', expectedSelection: undefined }, + { workflowIndex: 3, buildConfig: 'output_location', expectedSelection: undefined }, + { workflowIndex: 3, buildConfig: 'app_artifact_location', expectedSelection: undefined }, + + { workflowIndex: 4, buildConfig: 'api_location', expectedSelection: { line: 30, startChar: 24, endChar: 39 }}, + { workflowIndex: 4, buildConfig: 'app_location', expectedSelection: { line: 29, startChar: 24, endChar: 57 }}, + { workflowIndex: 4, buildConfig: 'output_location', expectedSelection: { line: 31, startChar: 27, endChar: 54 }}, + { workflowIndex: 4, buildConfig: 'app_artifact_location', expectedSelection: undefined }, + ]; + + const workflowProvider: TextDocumentContentProvider = new (class implements TextDocumentContentProvider { + provideTextDocumentContent(uri: Uri): string { + return workflows[parseInt(uri.path)]; + } + })(); + const scheme: string = 'testWorkflows'; + workspace.registerTextDocumentContentProvider(scheme, workflowProvider); + + for (const testCase of testCases) { + const title: string = `Workflow ${testCase.workflowIndex}: ${testCase.buildConfig}`; + + test(title, async () => { + const uri: Uri = Uri.parse(`${scheme}:${testCase.workflowIndex}`); + const configDocument: TextDocument = await workspace.openTextDocument(uri); + const selection: Range | undefined = await tryGetSelection(configDocument, testCase.buildConfig); + let expectedSelection: Range | undefined; + + if (testCase.expectedSelection) { + const expectedStart: Position = new Position(testCase.expectedSelection.line, testCase.expectedSelection.startChar); + const expectedEnd: Position = new Position(testCase.expectedSelection.line, testCase.expectedSelection.endChar); + expectedSelection = new Range(expectedStart, expectedEnd); + } + + assert.ok(selection && expectedSelection && selection.isEqual(expectedSelection) || selection === expectedSelection, 'Actual and expected selections do not match'); + }); + } +}); + +const workflows: string[] = [ +`jobs: + build_and_deploy_job: + steps: + - uses: Azure/static-web-apps-deploy@v0.0.1-preview + id: builddeploy + with: + app_location: "/" + api_location: "" + output_location: ""`, + +`jobs: + build_and_deploy_job: + steps: + - uses: Azure/static-web-apps-deploy@v0.0.1-preview + id: builddeploy + with: + app_location: "app/location" + api_location: 'api/location' + app_artifact_location: app/artifact/location`, + +`jobs: + build_and_deploy_job: + steps: + - uses: Azure/static-web-apps-deploy@v0.0.1-preview + id: builddeploy + with: + app_location: 你会说中文吗 # 你会说中文吗 + api_location: $p3c!@L-ÇhärãçΤΕrs &()%^*? # Comment + output_location: "한국어 할 줄 아세요"`, + +`jobs: + build_and_deploy_job1: + steps: + - uses: Azure/static-web-apps-deploy@v0.0.1-preview + id: builddeploy + with: + app_location: "src" + api_location: "api" + output_location: "build" + + build_and_deploy_job2: + steps: + - uses: Azure/static-web-apps-deploy@v0.0.1-preview + id: builddeploy + with: + app_location: "src" + api_location: "api" + output_location: "build"`, + +`name: Azure Static Web Apps CI/CD + +on: + push: + branches: + - master + pull_request: + types: [opened, synchronize, reopened, closed] + branches: + - master + +jobs: + build_and_deploy_job: + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') + runs-on: ubuntu-latest + name: Build and Deploy Job + steps: + - uses: actions/checkout@v2 + with: + submodules: true + - name: Build And Deploy + id: builddeploy + uses: Azure/static-web-apps-deploy@v0.0.1-preview + with: + azure_static_web_apps_api_token: $\{{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_AMBITIOUS_ROCK_0D992521E }} + repo_token: $\{{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments) + action: "upload" + ###### Repository/Build Configurations - These values can be configured to match your app requirements. ###### + # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig + app_location: "super/long/path/to/app/location" # There are tabs before this comment + api_location: 'single/quotes' #There are spaces before this comment + output_location: output/location with/spaces # There is a single space before this comment + ###### End of Repository/Build Configurations ###### + + close_pull_request_job: + if: github.event_name == 'pull_request' && github.event.action == 'closed' + runs-on: ubuntu-latest + name: Close Pull Request Job + steps: + - name: Close Pull Request + id: closepullrequest + uses: Azure/static-web-apps-deploy@v0.0.1-preview + with: + azure_static_web_apps_api_token: $\{{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_AMBITIOUS_ROCK_0D992521E }} + action: "close" + +`];