From 46fbaf06575aa6861cb882f560da337b125b1833 Mon Sep 17 00:00:00 2001 From: Koen Vlaswinkel Date: Tue, 18 Apr 2023 13:48:20 +0200 Subject: [PATCH] Filter extension packs by database item language This will filter the extension packs shown to the user when selecting an extension pack to use in the data extension editor to only include the extension packs that are compatible with the language of the database item. Unfortunately, this required quite some changes to the tests to ensure the extension packs are actually setup properly since it's now reading the extension pack files. --- .../extension-pack-picker.ts | 233 +++++--- .../extension-pack-picker.test.ts | 544 ++++++++++-------- 2 files changed, 459 insertions(+), 318 deletions(-) diff --git a/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts b/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts index 99df583a222..b0d102c6e6f 100644 --- a/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts +++ b/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts @@ -12,6 +12,7 @@ import { import { ProgressCallback } from "../progress"; import { DatabaseItem } from "../local-databases"; import { getQlPackPath, QLPACK_FILENAMES } from "../pure/ql"; +import { getErrorMessage } from "../pure/helpers-pure"; const maxStep = 3; @@ -22,8 +23,14 @@ const packNameRegex = new RegExp( const packNameLength = 128; export interface ExtensionPack { - name: string; path: string; + yamlPath: string; + + name: string; + version: string; + + extensionTargets: Record; + dataExtensions: string[]; } export interface ExtensionPackModelFile { @@ -50,7 +57,7 @@ export async function pickExtensionPackModelFile( const modelFile = await pickModelFile( cliServer, databaseItem, - extensionPack.path, + extensionPack, progress, token, ); @@ -78,19 +85,72 @@ async function pickExtensionPack( // Get all existing extension packs in the workspace const additionalPacks = getOnDiskWorkspaceFolders(); - const extensionPacks = await cliServer.resolveQlpacks(additionalPacks, true); + const extensionPacksInfo = await cliServer.resolveQlpacks( + additionalPacks, + true, + ); - if (Object.keys(extensionPacks).length === 0) { + if (Object.keys(extensionPacksInfo).length === 0) { return pickNewExtensionPack(databaseItem, token); } - const options: Array<{ label: string; extensionPack: string | null }> = - Object.keys(extensionPacks).map((pack) => ({ - label: pack, - extensionPack: pack, - })); + const extensionPacks = ( + await Promise.all( + Object.entries(extensionPacksInfo).map(async ([name, paths]) => { + if (paths.length !== 1) { + void showAndLogErrorMessage( + `Extension pack ${name} resolves to multiple paths`, + { + fullMessage: `Extension pack ${name} resolves to multiple paths: ${paths.join( + ", ", + )}`, + }, + ); + + return undefined; + } + + const path = paths[0]; + + let extensionPack: ExtensionPack; + try { + extensionPack = await readExtensionPack(path); + } catch (e: unknown) { + void showAndLogErrorMessage(`Could not read extension pack ${name}`, { + fullMessage: `Could not read extension pack ${name} at ${path}: ${getErrorMessage( + e, + )}`, + }); + + return undefined; + } + + return extensionPack; + }), + ) + ).filter((info): info is ExtensionPack => info !== undefined); + + const extensionPacksForLanguage = extensionPacks.filter( + (pack) => + pack.extensionTargets[`codeql/${databaseItem.language}-all`] !== + undefined, + ); + + const options: Array<{ + label: string; + description: string | undefined; + detail: string | undefined; + extensionPack: ExtensionPack | null; + }> = extensionPacksForLanguage.map((pack) => ({ + label: pack.name, + description: pack.version, + detail: pack.path, + extensionPack: pack, + })); options.push({ label: "Create new extension pack", + description: undefined, + detail: undefined, extensionPack: null, }); @@ -115,57 +175,39 @@ async function pickExtensionPack( return pickNewExtensionPack(databaseItem, token); } - const extensionPackPaths = extensionPacks[extensionPackOption.extensionPack]; - if (extensionPackPaths.length !== 1) { - void showAndLogErrorMessage( - `Extension pack ${extensionPackOption.extensionPack} could not be resolved to a single location`, - { - fullMessage: `Extension pack ${ - extensionPackOption.extensionPack - } could not be resolved to a single location. Found ${ - extensionPackPaths.length - } locations: ${extensionPackPaths.join(", ")}.`, - }, - ); - return undefined; - } - - return { - name: extensionPackOption.extensionPack, - path: extensionPackPaths[0], - }; + return extensionPackOption.extensionPack; } async function pickModelFile( cliServer: Pick, databaseItem: Pick, - extensionPackPath: string, + extensionPack: ExtensionPack, progress: ProgressCallback, token: CancellationToken, ): Promise { // Find the existing model files in the extension pack const additionalPacks = getOnDiskWorkspaceFolders(); const extensions = await cliServer.resolveExtensions( - extensionPackPath, + extensionPack.path, additionalPacks, ); const modelFiles = new Set(); - if (extensionPackPath in extensions.data) { - for (const extension of extensions.data[extensionPackPath]) { + if (extensionPack.path in extensions.data) { + for (const extension of extensions.data[extensionPack.path]) { modelFiles.add(extension.file); } } if (modelFiles.size === 0) { - return pickNewModelFile(databaseItem, extensionPackPath, token); + return pickNewModelFile(databaseItem, extensionPack, token); } const fileOptions: Array<{ label: string; file: string | null }> = []; for (const file of modelFiles) { fileOptions.push({ - label: relative(extensionPackPath, file).replaceAll(sep, "/"), + label: relative(extensionPack.path, file).replaceAll(sep, "/"), file, }); } @@ -196,7 +238,7 @@ async function pickModelFile( return fileOption.file; } - return pickNewModelFile(databaseItem, extensionPackPath, token); + return pickNewModelFile(databaseItem, extensionPack, token); } async function pickNewExtensionPack( @@ -266,66 +308,36 @@ async function pickNewExtensionPack( const packYamlPath = join(packPath, "codeql-pack.yml"); + const extensionPack: ExtensionPack = { + path: packPath, + yamlPath: packYamlPath, + name, + version: "0.0.0", + extensionTargets: { + [`codeql/${databaseItem.language}-all`]: "*", + }, + dataExtensions: ["models/**/*.yml"], + }; + await outputFile( packYamlPath, dumpYaml({ - name, - version: "0.0.0", + name: extensionPack.name, + version: extensionPack.version, library: true, - extensionTargets: { - [`codeql/${databaseItem.language}-all`]: "*", - }, - dataExtensions: ["models/**/*.yml"], + extensionTargets: extensionPack.extensionTargets, + dataExtensions: extensionPack.dataExtensions, }), ); - return { - name: packName, - path: packPath, - }; + return extensionPack; } async function pickNewModelFile( databaseItem: Pick, - extensionPackPath: string, + extensionPack: ExtensionPack, token: CancellationToken, ) { - const qlpackPath = await getQlPackPath(extensionPackPath); - if (!qlpackPath) { - void showAndLogErrorMessage( - `Could not find any of ${QLPACK_FILENAMES.join( - ", ", - )} in ${extensionPackPath}`, - ); - return undefined; - } - - const qlpack = await loadYaml(await readFile(qlpackPath, "utf8"), { - filename: qlpackPath, - }); - if (typeof qlpack !== "object" || qlpack === null) { - void showAndLogErrorMessage(`Could not parse ${qlpackPath}`); - return undefined; - } - - const dataExtensionPatternsValue = qlpack.dataExtensions; - if ( - !( - Array.isArray(dataExtensionPatternsValue) || - typeof dataExtensionPatternsValue === "string" - ) - ) { - void showAndLogErrorMessage( - `Expected 'dataExtensions' to be a string or an array in ${qlpackPath}`, - ); - return undefined; - } - - // The YAML allows either a string or an array of strings - const dataExtensionPatterns = Array.isArray(dataExtensionPatternsValue) - ? dataExtensionPatternsValue - : [dataExtensionPatternsValue]; - const filename = await window.showInputBox( { title: "Enter the name of the new model file", @@ -335,24 +347,25 @@ async function pickNewModelFile( return "File name must not be empty"; } - const path = resolve(extensionPackPath, value); + const path = resolve(extensionPack.path, value); if (await pathExists(path)) { return "File already exists"; } - const notInExtensionPack = relative(extensionPackPath, path).startsWith( - "..", - ); + const notInExtensionPack = relative( + extensionPack.path, + path, + ).startsWith(".."); if (notInExtensionPack) { return "File must be in the extension pack"; } - const matchesPattern = dataExtensionPatterns.some((pattern) => + const matchesPattern = extensionPack.dataExtensions.some((pattern) => minimatch(value, pattern, { matchBase: true }), ); if (!matchesPattern) { - return `File must match one of the patterns in 'dataExtensions' in ${qlpackPath}`; + return `File must match one of the patterns in 'dataExtensions' in ${extensionPack.yamlPath}`; } return undefined; @@ -364,5 +377,47 @@ async function pickNewModelFile( return undefined; } - return resolve(extensionPackPath, filename); + return resolve(extensionPack.path, filename); +} + +async function readExtensionPack(path: string): Promise { + const qlpackPath = await getQlPackPath(path); + if (!qlpackPath) { + throw new Error( + `Could not find any of ${QLPACK_FILENAMES.join(", ")} in ${path}`, + ); + } + + const qlpack = await loadYaml(await readFile(qlpackPath, "utf8"), { + filename: qlpackPath, + }); + if (typeof qlpack !== "object" || qlpack === null) { + throw new Error(`Could not parse ${qlpackPath}`); + } + + const dataExtensionValue = qlpack.dataExtensions; + if ( + !( + Array.isArray(dataExtensionValue) || + typeof dataExtensionValue === "string" + ) + ) { + throw new Error( + `Expected 'dataExtensions' to be a string or an array in ${qlpackPath}`, + ); + } + + // The YAML allows either a string or an array of strings + const dataExtensions = Array.isArray(dataExtensionValue) + ? dataExtensionValue + : [dataExtensionValue]; + + return { + path, + yamlPath: qlpackPath, + name: qlpack.name, + version: qlpack.version, + extensionTargets: qlpack.extensionTargets, + dataExtensions, + }; } diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extension-pack-picker.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extension-pack-picker.test.ts index 7963902c1d8..80662b0a3ce 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extension-pack-picker.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extension-pack-picker.test.ts @@ -3,28 +3,23 @@ import { dump as dumpYaml, load as loadYaml } from "js-yaml"; import { outputFile, readFile } from "fs-extra"; import { join } from "path"; import { dir } from "tmp-promise"; - -import { pickExtensionPackModelFile } from "../../../../src/data-extensions-editor/extension-pack-picker"; import { QlpacksInfo, ResolveExtensionsResult } from "../../../../src/cli"; import * as helpers from "../../../../src/helpers"; +import { + ExtensionPack, + pickExtensionPackModelFile, +} from "../../../../src/data-extensions-editor/extension-pack-picker"; + describe("pickExtensionPackModelFile", () => { - const qlPacks = { - "my-extension-pack": ["/a/b/c/my-extension-pack"], - "another-extension-pack": ["/a/b/c/another-extension-pack"], - }; - const extensions = { - models: [], - data: { - "/a/b/c/my-extension-pack": [ - { - file: "/a/b/c/my-extension-pack/models/model.yml", - index: 0, - predicate: "sinkModel", - }, - ], - }, - }; + let tmpDir: string; + let extensionPackPath: string; + let anotherExtensionPackPath: string; + let extensionPack: ExtensionPack; + let anotherExtensionPack: ExtensionPack; + + let qlPacks: QlpacksInfo; + let extensions: ResolveExtensionsResult; const databaseItem = { name: "github/vscode-codeql", language: "java", @@ -40,7 +35,42 @@ describe("pickExtensionPackModelFile", () => { typeof helpers.showAndLogErrorMessage >; - beforeEach(() => { + beforeEach(async () => { + tmpDir = ( + await dir({ + unsafeCleanup: true, + }) + ).path; + + extensionPackPath = join(tmpDir, "my-extension-pack"); + anotherExtensionPackPath = join(tmpDir, "another-extension-pack"); + + qlPacks = { + "my-extension-pack": [extensionPackPath], + "another-extension-pack": [anotherExtensionPackPath], + }; + extensions = { + models: [], + data: { + [extensionPackPath]: [ + { + file: join(extensionPackPath, "models", "model.yml"), + index: 0, + predicate: "sinkModel", + }, + ], + }, + }; + + extensionPack = await createMockExtensionPack( + extensionPackPath, + "my-extension-pack", + ); + anotherExtensionPack = await createMockExtensionPack( + anotherExtensionPackPath, + "another-extension-pack", + ); + showQuickPickSpy = jest .spyOn(window, "showQuickPick") .mockRejectedValue(new Error("Unexpected call to showQuickPick")); @@ -55,15 +85,17 @@ describe("pickExtensionPackModelFile", () => { }); it("allows choosing an existing extension pack and model file", async () => { + const modelPath = join(extensionPackPath, "models", "model.yml"); + const cliServer = mockCliServer(qlPacks, extensions); showQuickPickSpy.mockResolvedValueOnce({ label: "my-extension-pack", - extensionPack: "my-extension-pack", + extensionPack, } as QuickPickItem); showQuickPickSpy.mockResolvedValueOnce({ label: "models/model.yml", - file: "/a/b/c/my-extension-pack/models/model.yml", + file: modelPath, } as QuickPickItem); expect( @@ -74,22 +106,23 @@ describe("pickExtensionPackModelFile", () => { token, ), ).toEqual({ - filename: "/a/b/c/my-extension-pack/models/model.yml", - extensionPack: { - name: "my-extension-pack", - path: "/a/b/c/my-extension-pack", - }, + filename: modelPath, + extensionPack, }); expect(showQuickPickSpy).toHaveBeenCalledTimes(2); expect(showQuickPickSpy).toHaveBeenCalledWith( [ { label: "my-extension-pack", - extensionPack: "my-extension-pack", + description: "0.0.0", + detail: extensionPackPath, + extensionPack, }, { label: "another-extension-pack", - extensionPack: "another-extension-pack", + description: "0.0.0", + detail: anotherExtensionPackPath, + extensionPack: anotherExtensionPack, }, { label: expect.stringMatching(/create/i), @@ -105,7 +138,7 @@ describe("pickExtensionPackModelFile", () => { [ { label: "models/model.yml", - file: "/a/b/c/my-extension-pack/models/model.yml", + file: modelPath, }, { label: expect.stringMatching(/create/i), @@ -121,38 +154,17 @@ describe("pickExtensionPackModelFile", () => { expect(cliServer.resolveQlpacks).toHaveBeenCalledWith([], true); expect(cliServer.resolveExtensions).toHaveBeenCalledTimes(1); expect(cliServer.resolveExtensions).toHaveBeenCalledWith( - "/a/b/c/my-extension-pack", + extensionPackPath, [], ); }); it("allows choosing an existing extension pack and creating a new model file", async () => { - const tmpDir = await dir({ - unsafeCleanup: true, - }); - - const cliServer = mockCliServer( - { - ...qlPacks, - "my-extension-pack": [tmpDir.path], - }, - { - models: extensions.models, - data: { - [tmpDir.path]: [ - { - file: join(tmpDir.path, "models/model.yml"), - index: 0, - predicate: "sinkModel", - }, - ], - }, - }, - ); + const cliServer = mockCliServer(qlPacks, extensions); showQuickPickSpy.mockResolvedValueOnce({ label: "my-extension-pack", - extensionPack: "my-extension-pack", + extensionPack, } as QuickPickItem); showQuickPickSpy.mockResolvedValueOnce({ label: "create", @@ -160,19 +172,6 @@ describe("pickExtensionPackModelFile", () => { } as QuickPickItem); showInputBoxSpy.mockResolvedValue("models/my-model.yml"); - await outputFile( - join(tmpDir.path, "codeql-pack.yml"), - dumpYaml({ - name: "my-extension-pack", - version: "0.0.0", - library: true, - extensionTargets: { - "codeql/java-all": "*", - }, - dataExtensions: ["models/**/*.yml"], - }), - ); - expect( await pickExtensionPackModelFile( cliServer, @@ -181,49 +180,10 @@ describe("pickExtensionPackModelFile", () => { token, ), ).toEqual({ - filename: join(tmpDir.path, "models/my-model.yml"), - extensionPack: { - name: "my-extension-pack", - path: tmpDir.path, - }, + filename: join(extensionPackPath, "models", "my-model.yml"), + extensionPack, }); expect(showQuickPickSpy).toHaveBeenCalledTimes(2); - expect(showQuickPickSpy).toHaveBeenCalledWith( - [ - { - label: "my-extension-pack", - extensionPack: "my-extension-pack", - }, - { - label: "another-extension-pack", - extensionPack: "another-extension-pack", - }, - { - label: expect.stringMatching(/create/i), - extensionPack: null, - }, - ], - { - title: expect.any(String), - }, - token, - ); - expect(showQuickPickSpy).toHaveBeenCalledWith( - [ - { - label: "models/model.yml", - file: join(tmpDir.path, "models/model.yml"), - }, - { - label: expect.stringMatching(/create/i), - file: null, - }, - ], - { - title: expect.any(String), - }, - token, - ); expect(showInputBoxSpy).toHaveBeenCalledWith( { title: expect.any(String), @@ -235,7 +195,10 @@ describe("pickExtensionPackModelFile", () => { expect(cliServer.resolveQlpacks).toHaveBeenCalledTimes(1); expect(cliServer.resolveQlpacks).toHaveBeenCalledWith([], true); expect(cliServer.resolveExtensions).toHaveBeenCalledTimes(1); - expect(cliServer.resolveExtensions).toHaveBeenCalledWith(tmpDir.path, []); + expect(cliServer.resolveExtensions).toHaveBeenCalledWith( + extensionPackPath, + [], + ); }); it("allows cancelling the extension pack prompt", async () => { @@ -262,11 +225,13 @@ describe("pickExtensionPackModelFile", () => { unsafeCleanup: true, }); + const newPackDir = join(tmpDir.path, "new-extension-pack"); + showQuickPickSpy.mockResolvedValueOnce({ label: "codeql-custom-queries-java", path: tmpDir.path, } as QuickPickItem); - showInputBoxSpy.mockResolvedValueOnce("my-extension-pack"); + showInputBoxSpy.mockResolvedValueOnce("new-extension-pack"); showInputBoxSpy.mockResolvedValue("models/my-model.yml"); expect( @@ -277,15 +242,16 @@ describe("pickExtensionPackModelFile", () => { token, ), ).toEqual({ - filename: join( - tmpDir.path, - "my-extension-pack", - "models", - "my-model.yml", - ), + filename: join(newPackDir, "models", "my-model.yml"), extensionPack: { - name: "my-extension-pack", - path: join(tmpDir.path, "my-extension-pack"), + path: newPackDir, + yamlPath: join(newPackDir, "codeql-pack.yml"), + name: "new-extension-pack", + version: "0.0.0", + extensionTargets: { + "codeql/java-all": "*", + }, + dataExtensions: ["models/**/*.yml"], }, }); expect(showQuickPickSpy).toHaveBeenCalledTimes(1); @@ -311,14 +277,9 @@ describe("pickExtensionPackModelFile", () => { expect(cliServer.resolveExtensions).toHaveBeenCalled(); expect( - loadYaml( - await readFile( - join(tmpDir.path, "my-extension-pack", "codeql-pack.yml"), - "utf8", - ), - ), + loadYaml(await readFile(join(newPackDir, "codeql-pack.yml"), "utf8")), ).toEqual({ - name: "my-extension-pack", + name: "new-extension-pack", version: "0.0.0", library: true, extensionTargets: { @@ -335,11 +296,13 @@ describe("pickExtensionPackModelFile", () => { unsafeCleanup: true, }); + const newPackDir = join(tmpDir.path, "new-extension-pack"); + showQuickPickSpy.mockResolvedValueOnce({ label: "codeql-custom-queries-java", path: tmpDir.path, } as QuickPickItem); - showInputBoxSpy.mockResolvedValueOnce("my-extension-pack"); + showInputBoxSpy.mockResolvedValueOnce("new-extension-pack"); showInputBoxSpy.mockResolvedValue("models/my-model.yml"); expect( @@ -353,15 +316,16 @@ describe("pickExtensionPackModelFile", () => { token, ), ).toEqual({ - filename: join( - tmpDir.path, - "my-extension-pack", - "models", - "my-model.yml", - ), + filename: join(newPackDir, "models", "my-model.yml"), extensionPack: { - name: "my-extension-pack", - path: join(tmpDir.path, "my-extension-pack"), + path: newPackDir, + yamlPath: join(newPackDir, "codeql-pack.yml"), + name: "new-extension-pack", + version: "0.0.0", + extensionTargets: { + "codeql/csharp-all": "*", + }, + dataExtensions: ["models/**/*.yml"], }, }); expect(showQuickPickSpy).toHaveBeenCalledTimes(1); @@ -387,14 +351,9 @@ describe("pickExtensionPackModelFile", () => { expect(cliServer.resolveExtensions).toHaveBeenCalled(); expect( - loadYaml( - await readFile( - join(tmpDir.path, "my-extension-pack", "codeql-pack.yml"), - "utf8", - ), - ), + loadYaml(await readFile(join(newPackDir, "codeql-pack.yml"), "utf8")), ).toEqual({ - name: "my-extension-pack", + name: "new-extension-pack", version: "0.0.0", library: true, extensionTargets: { @@ -459,10 +418,7 @@ describe("pickExtensionPackModelFile", () => { { models: [], data: {} }, ); - showQuickPickSpy.mockResolvedValueOnce({ - label: "my-extension-pack", - extensionPack: "my-extension-pack", - } as QuickPickItem); + showQuickPickSpy.mockResolvedValueOnce(undefined); expect( await pickExtensionPackModelFile( @@ -474,10 +430,22 @@ describe("pickExtensionPackModelFile", () => { ).toEqual(undefined); expect(showAndLogErrorMessageSpy).toHaveBeenCalledTimes(1); expect(showAndLogErrorMessageSpy).toHaveBeenCalledWith( - expect.stringMatching(/could not be resolved to a single location/), + expect.stringMatching(/resolves to multiple paths/), expect.anything(), ); expect(showQuickPickSpy).toHaveBeenCalledTimes(1); + expect(showQuickPickSpy).toHaveBeenCalledWith( + [ + { + label: expect.stringMatching(/create/i), + extensionPack: null, + }, + ], + { + title: "Select extension pack to use", + }, + token, + ); expect(cliServer.resolveQlpacks).toHaveBeenCalled(); expect(cliServer.resolveExtensions).not.toHaveBeenCalled(); }); @@ -487,7 +455,7 @@ describe("pickExtensionPackModelFile", () => { showQuickPickSpy.mockResolvedValueOnce({ label: "my-extension-pack", - extensionPack: "my-extension-pack", + extensionPack, } as QuickPickItem); showQuickPickSpy.mockResolvedValueOnce(undefined); @@ -508,29 +476,21 @@ describe("pickExtensionPackModelFile", () => { unsafeCleanup: true, }); + const extensionPack = await createMockExtensionPack( + tmpDir.path, + "no-extension-pack", + ); + const cliServer = mockCliServer( { - "my-extension-pack": [tmpDir.path], + "no-extension-pack": [tmpDir.path], }, { models: [], data: {} }, ); - await outputFile( - join(tmpDir.path, "codeql-pack.yml"), - dumpYaml({ - name: "my-extension-pack", - version: "0.0.0", - library: true, - extensionTargets: { - "codeql/java-all": "*", - }, - dataExtensions: ["models/**/*.yml"], - }), - ); - showQuickPickSpy.mockResolvedValueOnce({ - label: "my-extension-pack", - extensionPack: "my-extension-pack", + label: "no-extension-pack", + extensionPack, } as QuickPickItem); showQuickPickSpy.mockResolvedValueOnce(undefined); showInputBoxSpy.mockResolvedValue("models/my-model.yml"); @@ -544,10 +504,7 @@ describe("pickExtensionPackModelFile", () => { ), ).toEqual({ filename: join(tmpDir.path, "models", "my-model.yml"), - extensionPack: { - name: "my-extension-pack", - path: tmpDir.path, - }, + extensionPack, }); expect(showQuickPickSpy).toHaveBeenCalledTimes(1); expect(showInputBoxSpy).toHaveBeenCalledWith( @@ -574,10 +531,6 @@ describe("pickExtensionPackModelFile", () => { { models: [], data: {} }, ); - showQuickPickSpy.mockResolvedValueOnce({ - label: "my-extension-pack", - extensionPack: "my-extension-pack", - } as QuickPickItem); showQuickPickSpy.mockResolvedValueOnce(undefined); showAndLogErrorMessageSpy.mockResolvedValue(undefined); @@ -590,13 +543,26 @@ describe("pickExtensionPackModelFile", () => { ), ).toEqual(undefined); expect(showQuickPickSpy).toHaveBeenCalledTimes(1); + expect(showQuickPickSpy).toHaveBeenCalledWith( + [ + { + label: expect.stringMatching(/create/i), + extensionPack: null, + }, + ], + { + title: "Select extension pack to use", + }, + token, + ); expect(showInputBoxSpy).not.toHaveBeenCalled(); expect(showAndLogErrorMessageSpy).toHaveBeenCalledTimes(1); expect(showAndLogErrorMessageSpy).toHaveBeenCalledWith( - expect.stringMatching(/codeql-pack\.yml/), + expect.stringMatching(/my-extension-pack/), + expect.anything(), ); expect(cliServer.resolveQlpacks).toHaveBeenCalled(); - expect(cliServer.resolveExtensions).toHaveBeenCalled(); + expect(cliServer.resolveExtensions).not.toHaveBeenCalled(); }); it("shows an error when the pack YAML file is invalid", async () => { @@ -613,10 +579,6 @@ describe("pickExtensionPackModelFile", () => { await outputFile(join(tmpDir.path, "codeql-pack.yml"), dumpYaml("java")); - showQuickPickSpy.mockResolvedValueOnce({ - label: "my-extension-pack", - extensionPack: "my-extension-pack", - } as QuickPickItem); showQuickPickSpy.mockResolvedValueOnce(undefined); showAndLogErrorMessageSpy.mockResolvedValue(undefined); @@ -629,13 +591,26 @@ describe("pickExtensionPackModelFile", () => { ), ).toEqual(undefined); expect(showQuickPickSpy).toHaveBeenCalledTimes(1); + expect(showQuickPickSpy).toHaveBeenCalledWith( + [ + { + label: expect.stringMatching(/create/i), + extensionPack: null, + }, + ], + { + title: "Select extension pack to use", + }, + token, + ); expect(showInputBoxSpy).not.toHaveBeenCalled(); expect(showAndLogErrorMessageSpy).toHaveBeenCalledTimes(1); expect(showAndLogErrorMessageSpy).toHaveBeenCalledWith( - expect.stringMatching(/Could not parse/), + expect.stringMatching(/my-extension-pack/), + expect.anything(), ); expect(cliServer.resolveQlpacks).toHaveBeenCalled(); - expect(cliServer.resolveExtensions).toHaveBeenCalled(); + expect(cliServer.resolveExtensions).not.toHaveBeenCalled(); }); it("shows an error when the pack YAML does not contain dataExtensions", async () => { @@ -662,10 +637,6 @@ describe("pickExtensionPackModelFile", () => { }), ); - showQuickPickSpy.mockResolvedValueOnce({ - label: "my-extension-pack", - extensionPack: "my-extension-pack", - } as QuickPickItem); showQuickPickSpy.mockResolvedValueOnce(undefined); showAndLogErrorMessageSpy.mockResolvedValue(undefined); @@ -678,13 +649,26 @@ describe("pickExtensionPackModelFile", () => { ), ).toEqual(undefined); expect(showQuickPickSpy).toHaveBeenCalledTimes(1); + expect(showQuickPickSpy).toHaveBeenCalledWith( + [ + { + label: expect.stringMatching(/create/i), + extensionPack: null, + }, + ], + { + title: "Select extension pack to use", + }, + token, + ); expect(showInputBoxSpy).not.toHaveBeenCalled(); expect(showAndLogErrorMessageSpy).toHaveBeenCalledTimes(1); expect(showAndLogErrorMessageSpy).toHaveBeenCalledWith( - expect.stringMatching(/Expected 'dataExtensions' to be/), + expect.stringMatching(/my-extension-pack/), + expect.anything(), ); expect(cliServer.resolveQlpacks).toHaveBeenCalled(); - expect(cliServer.resolveExtensions).toHaveBeenCalled(); + expect(cliServer.resolveExtensions).not.toHaveBeenCalled(); }); it("shows an error when the pack YAML dataExtensions is invalid", async () => { @@ -714,10 +698,6 @@ describe("pickExtensionPackModelFile", () => { }), ); - showQuickPickSpy.mockResolvedValueOnce({ - label: "my-extension-pack", - extensionPack: "my-extension-pack", - } as QuickPickItem); showQuickPickSpy.mockResolvedValueOnce(undefined); showAndLogErrorMessageSpy.mockResolvedValue(undefined); @@ -730,13 +710,26 @@ describe("pickExtensionPackModelFile", () => { ), ).toEqual(undefined); expect(showQuickPickSpy).toHaveBeenCalledTimes(1); + expect(showQuickPickSpy).toHaveBeenCalledWith( + [ + { + label: expect.stringMatching(/create/i), + extensionPack: null, + }, + ], + { + title: "Select extension pack to use", + }, + token, + ); expect(showInputBoxSpy).not.toHaveBeenCalled(); expect(showAndLogErrorMessageSpy).toHaveBeenCalledTimes(1); expect(showAndLogErrorMessageSpy).toHaveBeenCalledWith( - expect.stringMatching(/Expected 'dataExtensions' to be/), + expect.stringMatching(/my-extension-pack/), + expect.anything(), ); expect(cliServer.resolveQlpacks).toHaveBeenCalled(); - expect(cliServer.resolveExtensions).toHaveBeenCalled(); + expect(cliServer.resolveExtensions).not.toHaveBeenCalled(); }); it("allows cancelling the new file input box", async () => { @@ -744,29 +737,24 @@ describe("pickExtensionPackModelFile", () => { unsafeCleanup: true, }); + const newExtensionPack = await createMockExtensionPack( + tmpDir.path, + "new-extension-pack", + ); + const cliServer = mockCliServer( { "my-extension-pack": [tmpDir.path], }, - { models: [], data: {} }, - ); - - await outputFile( - join(tmpDir.path, "codeql-pack.yml"), - dumpYaml({ - name: "my-extension-pack", - version: "0.0.0", - library: true, - extensionTargets: { - "codeql/java-all": "*", - }, - dataExtensions: ["models/**/*.yml"], - }), + { + models: [], + data: {}, + }, ); showQuickPickSpy.mockResolvedValueOnce({ - label: "my-extension-pack", - extensionPack: "my-extension-pack", + label: "new-extension-pack", + extensionPack: newExtensionPack, } as QuickPickItem); showQuickPickSpy.mockResolvedValueOnce(undefined); showInputBoxSpy.mockResolvedValue(undefined); @@ -833,36 +821,31 @@ describe("pickExtensionPackModelFile", () => { unsafeCleanup: true, }); + const extensionPack = await createMockExtensionPack( + tmpDir.path, + "new-extension-pack", + { + dataExtensions: ["models/**/*.yml", "data/**/*.yml"], + }, + ); + const cliServer = mockCliServer( { - "my-extension-pack": [tmpDir.path], + "new-extension-pack": [extensionPack.path], }, { models: [], data: {} }, ); - const qlpackPath = join(tmpDir.path, "codeql-pack.yml"); await outputFile( - qlpackPath, - dumpYaml({ - name: "my-extension-pack", - version: "0.0.0", - library: true, - extensionTargets: { - "codeql/java-all": "*", - }, - dataExtensions: ["models/**/*.yml", "data/**/*.yml"], - }), - ); - await outputFile( - join(tmpDir.path, "models", "model.yml"), + join(extensionPack.path, "models", "model.yml"), dumpYaml({ extensions: [], }), ); showQuickPickSpy.mockResolvedValueOnce({ - label: "my-extension-pack", - extensionPack: "my-extension-pack", + label: "new-extension-pack", + extensionPack, } as QuickPickItem); showQuickPickSpy.mockResolvedValueOnce(undefined); showInputBoxSpy.mockResolvedValue(undefined); @@ -893,10 +876,10 @@ describe("pickExtensionPackModelFile", () => { "File must be in the extension pack", ); expect(await validateFile("model.yml")).toEqual( - `File must match one of the patterns in 'dataExtensions' in ${qlpackPath}`, + `File must match one of the patterns in 'dataExtensions' in ${extensionPack.yamlPath}`, ); expect(await validateFile("models/model.yaml")).toEqual( - `File must match one of the patterns in 'dataExtensions' in ${qlpackPath}`, + `File must match one of the patterns in 'dataExtensions' in ${extensionPack.yamlPath}`, ); expect(await validateFile("models/my-model.yml")).toBeUndefined(); expect(await validateFile("models/nested/model.yml")).toBeUndefined(); @@ -910,7 +893,7 @@ describe("pickExtensionPackModelFile", () => { const cliServer = mockCliServer( { - "my-extension-pack": [tmpDir.path], + "new-extension-pack": [tmpDir.path], }, { models: [], data: {} }, ); @@ -919,7 +902,7 @@ describe("pickExtensionPackModelFile", () => { await outputFile( qlpackPath, dumpYaml({ - name: "my-extension-pack", + name: "new-extension-pack", version: "0.0.0", library: true, extensionTargets: { @@ -936,8 +919,17 @@ describe("pickExtensionPackModelFile", () => { ); showQuickPickSpy.mockResolvedValueOnce({ - label: "my-extension-pack", - extensionPack: "my-extension-pack", + label: "new-extension-pack", + extensionPack: { + path: tmpDir.path, + yamlPath: qlpackPath, + name: "new-extension-pack", + version: "0.0.0", + extensionTargets: { + "codeql/java-all": "*", + }, + dataExtensions: ["models/**/*.yml"], + }, } as QuickPickItem); showQuickPickSpy.mockResolvedValueOnce(undefined); showInputBoxSpy.mockResolvedValue(undefined); @@ -959,6 +951,63 @@ describe("pickExtensionPackModelFile", () => { expect(await validateFile("models/my-model.yml")).toBeUndefined(); }); + + it("only shows extension packs for the database language", async () => { + const csharpPack = await createMockExtensionPack( + join(tmpDir, "csharp-extensions"), + "csharp-extension-pack", + { + version: "0.5.3", + extensionTargets: { + "codeql/csharp-all": "*", + }, + }, + ); + + const cliServer = mockCliServer( + { + ...qlPacks, + "csharp-extension-pack": [csharpPack.path], + }, + extensions, + ); + + showQuickPickSpy.mockResolvedValueOnce(undefined); + + expect( + await pickExtensionPackModelFile( + cliServer, + { + ...databaseItem, + language: "csharp", + }, + progress, + token, + ), + ).toEqual(undefined); + expect(showQuickPickSpy).toHaveBeenCalledTimes(1); + expect(showQuickPickSpy).toHaveBeenCalledWith( + [ + { + label: "csharp-extension-pack", + description: "0.5.3", + detail: csharpPack.path, + extensionPack: csharpPack, + }, + { + label: expect.stringMatching(/create/i), + extensionPack: null, + }, + ], + { + title: expect.any(String), + }, + token, + ); + expect(cliServer.resolveQlpacks).toHaveBeenCalledTimes(1); + expect(cliServer.resolveQlpacks).toHaveBeenCalledWith([], true); + expect(cliServer.resolveExtensions).not.toHaveBeenCalled(); + }); }); function mockCliServer( @@ -970,3 +1019,40 @@ function mockCliServer( resolveExtensions: jest.fn().mockResolvedValue(extensions), }; } + +async function createMockExtensionPack( + path: string, + name: string, + data: Partial = {}, +): Promise { + const extensionPack: ExtensionPack = { + path, + yamlPath: join(path, "codeql-pack.yml"), + name, + version: "0.0.0", + extensionTargets: { + "codeql/java-all": "*", + }, + dataExtensions: ["models/**/*.yml"], + ...data, + }; + + await writeExtensionPackToDisk(extensionPack); + + return extensionPack; +} + +async function writeExtensionPackToDisk( + extensionPack: ExtensionPack, +): Promise { + await outputFile( + extensionPack.yamlPath, + dumpYaml({ + name: extensionPack.name, + version: extensionPack.version, + library: true, + extensionTargets: extensionPack.extensionTargets, + dataExtensions: extensionPack.dataExtensions, + }), + ); +}