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 99df583a2..b0d102c6e 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 7963902c1..80662b0a3 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, + }), + ); +}