diff --git a/extensions/ql-vscode/src/queries-panel/query-discovery.ts b/extensions/ql-vscode/src/queries-panel/query-discovery.ts index 00322d634..bde646fbb 100644 --- a/extensions/ql-vscode/src/queries-panel/query-discovery.ts +++ b/extensions/ql-vscode/src/queries-panel/query-discovery.ts @@ -12,7 +12,7 @@ import { QueryDiscoverer } from "./query-tree-data-provider"; /** * The results of discovering queries. */ -interface QueryDiscoveryResults { +export interface QueryDiscoveryResults { /** * A tree of directories and query files. * May have multiple roots because of multiple workspaces. @@ -99,22 +99,26 @@ export class QueryDiscovery ): Promise { const rootDirectories = []; for (const workspaceFolder of workspaceFolders) { - rootDirectories.push( - await this.discoverQueriesInWorkspace(workspaceFolder), - ); + const root = await this.discoverQueriesInWorkspace(workspaceFolder); + if (root !== undefined) { + rootDirectories.push(root); + } } return rootDirectories; } private async discoverQueriesInWorkspace( workspaceFolder: WorkspaceFolder, - ): Promise { + ): Promise { const fullPath = workspaceFolder.uri.fsPath; const name = workspaceFolder.name; - const rootDirectory = new FileTreeDirectory(fullPath, name); - const resolvedQueries = await this.cliServer.resolveQueries(fullPath); + if (resolvedQueries.length === 0) { + return undefined; + } + + const rootDirectory = new FileTreeDirectory(fullPath, name); for (const queryPath of resolvedQueries) { const relativePath = normalize(relative(fullPath, queryPath)); const dirName = dirname(relativePath); diff --git a/extensions/ql-vscode/test/vscode-tests/minimal-workspace/queries-panel/query-discovery.test.ts b/extensions/ql-vscode/test/vscode-tests/minimal-workspace/queries-panel/query-discovery.test.ts new file mode 100644 index 000000000..76052b5bd --- /dev/null +++ b/extensions/ql-vscode/test/vscode-tests/minimal-workspace/queries-panel/query-discovery.test.ts @@ -0,0 +1,225 @@ +import { + EventEmitter, + FileSystemWatcher, + Uri, + WorkspaceFoldersChangeEvent, + workspace, +} from "vscode"; +import { CodeQLCliServer } from "../../../../src/codeql-cli/cli"; +import { + QueryDiscovery, + QueryDiscoveryResults, +} from "../../../../src/queries-panel/query-discovery"; +import { createMockApp } from "../../../__mocks__/appMock"; +import { mockedObject } from "../../utils/mocking.helpers"; +import { basename, join, sep } from "path"; +import { sleep } from "../../../../src/pure/time"; + +describe("QueryDiscovery", () => { + beforeEach(() => { + expect(workspace.workspaceFolders?.length).toEqual(1); + }); + + describe("queries", () => { + it("should return empty list when no QL files are present", async () => { + const resolveQueries = jest.fn().mockResolvedValue([]); + const cli = mockedObject({ + resolveQueries, + }); + + const discovery = new QueryDiscovery(createMockApp({}), cli); + const results: QueryDiscoveryResults = await ( + discovery as any + ).discover(); + + expect(results.queries).toEqual([]); + expect(resolveQueries).toHaveBeenCalledTimes(1); + }); + + it("should organise query files into directories", async () => { + const workspaceRoot = workspace.workspaceFolders![0].uri.fsPath; + const cli = mockedObject({ + resolveQueries: jest + .fn() + .mockResolvedValue([ + join(workspaceRoot, "dir1/query1.ql"), + join(workspaceRoot, "dir2/query2.ql"), + join(workspaceRoot, "query3.ql"), + ]), + }); + + const discovery = new QueryDiscovery(createMockApp({}), cli); + const results: QueryDiscoveryResults = await ( + discovery as any + ).discover(); + + expect(results.queries[0].children.length).toEqual(3); + expect(results.queries[0].children[0].name).toEqual("dir1"); + expect(results.queries[0].children[0].children.length).toEqual(1); + expect(results.queries[0].children[0].children[0].name).toEqual( + "query1.ql", + ); + expect(results.queries[0].children[1].name).toEqual("dir2"); + expect(results.queries[0].children[1].children.length).toEqual(1); + expect(results.queries[0].children[1].children[0].name).toEqual( + "query2.ql", + ); + expect(results.queries[0].children[2].name).toEqual("query3.ql"); + }); + + it("should collapse directories containing only a single element", async () => { + const workspaceRoot = workspace.workspaceFolders![0].uri.fsPath; + const cli = mockedObject({ + resolveQueries: jest + .fn() + .mockResolvedValue([ + join(workspaceRoot, "dir1/query1.ql"), + join(workspaceRoot, "dir1/dir2/dir3/dir3/query2.ql"), + ]), + }); + + const discovery = new QueryDiscovery(createMockApp({}), cli); + const results: QueryDiscoveryResults = await ( + discovery as any + ).discover(); + + expect(results.queries[0].children.length).toEqual(1); + expect(results.queries[0].children[0].name).toEqual("dir1"); + expect(results.queries[0].children[0].children.length).toEqual(2); + expect(results.queries[0].children[0].children[0].name).toEqual( + "dir2 / dir3 / dir3", + ); + expect( + results.queries[0].children[0].children[0].children.length, + ).toEqual(1); + expect( + results.queries[0].children[0].children[0].children[0].name, + ).toEqual("query2.ql"); + expect(results.queries[0].children[0].children[1].name).toEqual( + "query1.ql", + ); + }); + + it("calls resolveQueries once for each workspace folder", async () => { + const workspaceRoots = [ + `${sep}workspace1`, + `${sep}workspace2`, + `${sep}workspace3`, + ]; + jest.spyOn(workspace, "workspaceFolders", "get").mockReturnValueOnce( + workspaceRoots.map((root, index) => ({ + uri: Uri.file(root), + name: basename(root), + index, + })), + ); + + const resolveQueries = jest.fn().mockImplementation((queryDir) => { + const workspaceIndex = workspaceRoots.indexOf(queryDir); + if (workspaceIndex === -1) { + throw new Error("Unexpected workspace"); + } + return Promise.resolve([ + join(queryDir, `query${workspaceIndex + 1}.ql`), + ]); + }); + const cli = mockedObject({ + resolveQueries, + }); + + const discovery = new QueryDiscovery(createMockApp({}), cli); + const results: QueryDiscoveryResults = await ( + discovery as any + ).discover(); + + expect(results.queries.length).toEqual(3); + expect(results.queries[0].children[0].name).toEqual("query1.ql"); + expect(results.queries[1].children[0].name).toEqual("query2.ql"); + expect(results.queries[2].children[0].name).toEqual("query3.ql"); + + expect(resolveQueries).toHaveBeenCalledTimes(3); + }); + }); + + describe("onDidChangeQueries", () => { + it("should fire onDidChangeQueries when a watcher fires", async () => { + const onWatcherDidChangeEvent = new EventEmitter(); + const watcher: FileSystemWatcher = { + ignoreCreateEvents: false, + ignoreChangeEvents: false, + ignoreDeleteEvents: false, + onDidCreate: onWatcherDidChangeEvent.event, + onDidChange: onWatcherDidChangeEvent.event, + onDidDelete: onWatcherDidChangeEvent.event, + dispose: () => undefined, + }; + const createFileSystemWatcherSpy = jest.spyOn( + workspace, + "createFileSystemWatcher", + ); + createFileSystemWatcherSpy.mockReturnValue(watcher); + + const workspaceRoot = workspace.workspaceFolders![0].uri.fsPath; + const cli = mockedObject({ + resolveQueries: jest + .fn() + .mockResolvedValue([join(workspaceRoot, "query1.ql")]), + }); + + const discovery = new QueryDiscovery( + createMockApp({ + createEventEmitter: () => new EventEmitter(), + }), + cli, + ); + + const onDidChangeQueriesSpy = jest.fn(); + discovery.onDidChangeQueries(onDidChangeQueriesSpy); + + const results = await (discovery as any).discover(); + (discovery as any).update(results); + + expect(createFileSystemWatcherSpy).toHaveBeenCalledTimes(2); + expect(onDidChangeQueriesSpy).toHaveBeenCalledTimes(1); + + onWatcherDidChangeEvent.fire(workspace.workspaceFolders![0].uri); + + // Wait for refresh to finish + await sleep(100); + + expect(onDidChangeQueriesSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe("onDidChangeWorkspaceFolders", () => { + it("should refresh when workspace folders change", async () => { + const onDidChangeWorkspaceFoldersEvent = + new EventEmitter(); + + const discovery = new QueryDiscovery( + createMockApp({ + createEventEmitter: () => new EventEmitter(), + onDidChangeWorkspaceFolders: onDidChangeWorkspaceFoldersEvent.event, + }), + mockedObject({ + resolveQueries: jest.fn().mockResolvedValue([]), + }), + ); + + const onDidChangeQueriesSpy = jest.fn(); + discovery.onDidChangeQueries(onDidChangeQueriesSpy); + + const results = await (discovery as any).discover(); + (discovery as any).update(results); + + expect(onDidChangeQueriesSpy).toHaveBeenCalledTimes(1); + + onDidChangeWorkspaceFoldersEvent.fire({ added: [], removed: [] }); + + // Wait for refresh to finish + await sleep(100); + + expect(onDidChangeQueriesSpy).toHaveBeenCalledTimes(2); + }); + }); +});