import { createTestingPinia } from "@pinia/testing"; import { getLocalVue, suppressDebugConsole } from "@tests/vitest/helpers"; import { mount, type Wrapper } from "@vue/test-utils"; import flushPromises from "flush-promises"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useServerMock } from "@/api/client/__mocks__"; import type { FileSourceTemplateSummary } from "@/api/fileSources"; import { SELECTION_STATES, type SelectionItem, type SelectionState } from "@/components/SelectionDialog/selectionTypes"; /** * The following imports mock a remote file resource directory structure, * which is navigated throughout the test cases further below. * The directory tree path is as follows: * * |-- directory1 * | |-- directory1file1 * | |-- directory1file2 * | |-- directory1file3 * | |-- subdirectory1 * | | `-- subsubdirectory * | | `-- subsubfile * | `-- subdirectory2 * | `-- subdirectory2file * |-- directory2 * | |-- directory2file1 * | `-- directory2file2 * |-- file1 * |-- file2 */ import type { RemoteFilesList } from "./testingData"; import { directory1RecursiveResponse, directory1Response, directory2RecursiveResponse, directoryId, ftpId, pdbResponse, rootId, rootResponse, someErrorText, subDirectoryId, subSubDirectoryId, subsubdirectoryResponse, } from "./testingData"; import FilesDialog from "./FilesDialog.vue"; import SelectionDialog from "@/components/SelectionDialog/SelectionDialog.vue"; vi.mock("app"); vi.mock("@/composables/config", () => ({ useConfig: vi.fn(() => ({ config: { ftp_upload_site: "Test ftp upload site" }, isConfigLoaded: true, })), })); const { server, http } = useServerMock(); interface RowElement extends SelectionItem, Element { _rowVariant: SelectionState; } function paramsToKey(query: { target?: string | null; recursive?: string | null; write_intent?: string | null; }): string { return `${query.target}?recursive=${query.recursive}&write_intent=${query.write_intent ?? "false"}`; } const mockedOkApiRoutesMap = new Map([ [paramsToKey({ target: "gxfiles://pdb-gzip", recursive: "false" }), pdbResponse], [paramsToKey({ target: "gxfiles://pdb-gzip/directory1", recursive: "false" }), directory1Response], [paramsToKey({ target: "gxfiles://pdb-gzip/directory1", recursive: "true" }), directory1RecursiveResponse], [paramsToKey({ target: "gxfiles://pdb-gzip/directory2", recursive: "true" }), directory2RecursiveResponse], [ paramsToKey({ target: "gxfiles://pdb-gzip/directory1/subdirectory1", recursive: "false" }), subsubdirectoryResponse, ], [paramsToKey({ target: "gxftp://", recursive: "false" }), pdbResponse], ]); const mockedErrorApiRoutesMap = new Map([ [paramsToKey({ target: "gxfiles://empty-dir", recursive: "false" }), []], ]); const initComponent = async (props: { multiple: boolean; mode?: string }, hasTemplates = false) => { const localVue = getLocalVue(); server.use( http.get("/api/remote_files/plugins", ({ response }) => { return response(200).json(rootResponse); }), http.get("/api/remote_files", ({ response, query }) => { const responseKey = paramsToKey({ target: query.get("target"), recursive: query.get("recursive"), write_intent: query.get("write_intent"), }); if (mockedErrorApiRoutesMap.has(responseKey)) { return response("4XX").json({ err_msg: someErrorText, err_code: 400 }, { status: 400 }); } const mockedResponse = mockedOkApiRoutesMap.get(responseKey); const mockedTotalMatches = mockedResponse?.length.toString() ?? "0"; if (!mockedResponse) { return response("5XX").json({ err_msg: "No mocked response found", err_code: 500 }, { status: 500 }); } return response(200).json(mockedResponse, { headers: { total_matches: mockedTotalMatches } }); }), http.get("/api/file_source_templates", ({ response }) => { const fileSourceTemplates = hasTemplates ? [{ id: "test_template" } as FileSourceTemplateSummary] : []; return response(200).json(fileSourceTemplates); }), ); const testingPinia = createTestingPinia({ createSpy: vi.fn, stubActions: false }); const wrapper = mount(FilesDialog as object, { localVue, propsData: { ...props }, pinia: testingPinia, }); await flushPromises(); return wrapper; }; describe("FilesDialog, file mode", () => { let wrapper: Wrapper; let utils: Utils; beforeEach(async () => { wrapper = await initComponent({ multiple: true }); utils = new Utils(wrapper); }); it("should show the number of items expected", async () => { await utils.openRootDirectory(); expect(utils.getRenderedRows().length).toBe(pdbResponse.length); }); it("should list the user defined file sources first", async () => { await utils.openRoot(); const rows = utils.getRenderedRows(); const firstItem = rows[0]; expect(firstItem).toBeDefined(); expect(firstItem!.url).toContain("gxuserfiles://"); }); it("should allow selecting files and update OK button accordingly", async () => { await utils.openRootDirectory(); const filesInResponse = pdbResponse.filter((item) => item.class === "File"); utils.expectOkButtonDisabled(); expect(utils.getRenderedFiles().length).toBe(filesInResponse.length); // select each file await utils.applyToEachFile((item) => utils.clickOn(item)); utils.expectNumberOfSelectedItemsToBe(filesInResponse.length); await utils.applyToEachFile((item) => { expect(item._rowVariant).toBe(SELECTION_STATES.SELECTED); }); utils.expectOkButtonEnabled(); // unselect each file await utils.applyToEachFile((item) => utils.clickOn(item)); utils.expectOkButtonDisabled(); }); it("should select all files contained in a directory when selecting the directory and update selection status accordingly", async () => { const targetDirectoryId = directoryId; await utils.openRootDirectory(); // select directory await utils.clickOn(utils.findRenderedDirectory(targetDirectoryId)); // go inside directory1 await utils.openDirectoryById(targetDirectoryId); utils.expectSelectAllIconStatusToBe(SELECTION_STATES.SELECTED); //every item should be selected utils.expectAllRenderedItemsSelected(); // unselect first file const firstFile = utils.findFirstFile(); await utils.clickOn(firstFile); await utils.navigateBack(); // ensure that it has "mixed" status icon const directory = utils.findRenderedDirectory(targetDirectoryId); expect(directory._rowVariant).toBe(SELECTION_STATES.MIXED); }); it("should be able to unselect a sub-directory keeping the rest selected", async () => { await utils.openRootDirectory(); // select directory1 await utils.clickOn(utils.findRenderedDirectory(directoryId)); //go inside subDirectoryId await utils.openDirectoryById(directoryId); await utils.openDirectoryById(subDirectoryId); // unselect subfolder await utils.clickOn(utils.findRenderedDirectory(subSubDirectoryId)); // directory should be unselected expect(utils.findRenderedDirectory(subSubDirectoryId)._rowVariant).toBe(SELECTION_STATES.UNSELECTED); // selectAllIcon should be unselected utils.expectSelectAllIconStatusToBe(SELECTION_STATES.UNSELECTED); await utils.navigateBack(); await utils.navigateBack(); expect(utils.findRenderedDirectory(directoryId)._rowVariant).toBe(SELECTION_STATES.MIXED); }); it("should select all on 'toggleSelectAll' event", async () => { await utils.openRootDirectory(); utils.selectAll(); utils.expectAllRenderedItemsSelected(); // open directory1 await utils.openDirectoryById(directoryId); utils.expectAllRenderedItemsSelected(); await utils.navigateBack(); utils.expectAllRenderedItemsSelected(); await utils.navigateBack(); const rootNode = utils.findRenderedDirectory(rootId); expect(rootNode._rowVariant).toBe(SELECTION_STATES.SELECTED); }); it("should show ftp helper only in ftp directory", async () => { // open some other directory than ftp await utils.openRootDirectory(); // check that ftp helper is not visible expect(wrapper.find("#helper").exists()).toBe(false); // back to root folder await utils.navigateBack(); // open ftp directory await utils.openDirectoryById(ftpId); // check that ftp helper is visible expect(wrapper.find("#helper").exists()).toBe(true); }); it("should show loading error and can return back when there is an error", async () => { utils.expectNoErrorMessage(); suppressDebugConsole(); // expecting error message. // open directory with error await utils.openDirectoryById("empty-dir"); utils.expectErrorMessage(); // back to the root folder await utils.navigateBack(); expect(utils.getRenderedRows().length).toBe(rootResponse.length); }); }); describe("FilesDialog, create new file source button", () => { let wrapper: Wrapper; let utils: Utils; beforeEach(async () => { const hasTemplates = true; wrapper = await initComponent({ multiple: false }, hasTemplates); utils = new Utils(wrapper); }); it("should not render create new button since file source templates are not defined", async () => { const hasTemplates = false; wrapper = await initComponent({ multiple: true }, hasTemplates); const createNewButton = wrapper.find("[data-description='create new file source button']"); expect(createNewButton.exists()).toBe(false); }); it("should render create new button since file source templates are defined and is at root", async () => { await utils.openRoot(); const createNewButton = wrapper.find("[data-description='create new file source button']"); expect(createNewButton.exists()).toBe(true); }); it("should not render create new button inside folders", async () => { await utils.openRootDirectory(); const createNewButton = wrapper.find("[data-description='create new file source button']"); expect(createNewButton.exists()).toBe(false); }); }); describe("FilesDialog, file mode with templates", () => { let wrapper: Wrapper; beforeEach(async () => { wrapper = await initComponent({ multiple: true }, true); }); it("should render create new button since file source templates are defined", async () => { const createNewButton = wrapper.find("[data-description='create new file source button']"); expect(createNewButton.exists()).toBe(true); }); }); describe("FilesDialog, directory mode", () => { let wrapper: Wrapper; let utils: Utils; beforeEach(async () => { wrapper = await initComponent({ multiple: false, mode: "directory" }); utils = new Utils(wrapper); }); it("should render directories only", async () => { const expectOnlyDirectoriesRendered = () => utils.getRenderedRows().forEach((item) => expect(item.isLeaf).toBe(false)); await utils.openRootDirectory(); // rendered files should be directories expectOnlyDirectoriesRendered(); // check subdirectories await utils.openDirectoryById(directoryId); expectOnlyDirectoriesRendered(); }); it("should allow to select folders by navigating to them", async () => { utils.expectOkButtonDisabled(); await utils.openRootDirectory(); utils.openDirectoryById(directoryId); utils.expectOkButtonEnabled(); }); it("should show loading error and can return back when there is an error", async () => { utils.expectNoErrorMessage(); suppressDebugConsole(); // expecting error message. // open directory with error await utils.openDirectoryById("empty-dir"); utils.expectErrorMessage(); // back to the root folder await utils.navigateBack(); expect(utils.getRenderedRows().length).toBe(rootResponse.length); }); }); class Utils { wrapper: Wrapper; constructor(wrapper: Wrapper) { this.wrapper = wrapper; } async openRoot() { expect(this.wrapper.findComponent(SelectionDialog).exists()).toBe(true); expect(this.getRenderedRows().length).toBe(rootResponse.length); } async openRootDirectory() { await this.openRoot(); await this.openDirectoryById(rootId); } async navigateBack() { const undoBtn = this.getUndoButton(); await undoBtn.trigger("click"); await flushPromises(); } async openDirectoryById(directoryId: string) { const directory = this.findRenderedDirectory(directoryId); return this.openDirectory(directory); } async openDirectory(directory: RowElement) { this.getSelectionDialog().vm.$emit("onOpen", directory); await flushPromises(); } async clickOn(element: Element) { this.getSelectionDialog().vm.$emit("onClick", element); await flushPromises(); } async selectAll() { this.getSelectionDialog().vm.$emit("onSelectAll"); await flushPromises(); } findRenderedDirectory(directoryId: string): RowElement { const directory = this.getRenderedRows().find(({ id }: RowElement) => directoryId === id); if (!directory) { throw new Error(`Directory with id ${directoryId} not found`); } return directory; } getRenderedFiles(): RowElement[] { return this.getRenderedRows().filter((item: RowElement) => item.isLeaf); } findFirstFile(): RowElement { const file = this.getRenderedFiles()[0]; if (!file) { throw new Error("File not found"); } return file; } async applyToEachFile(func: (item: RowElement) => void) { this.getRenderedFiles().forEach((item) => { func(item); }); await flushPromises(); } getSelectionDialog(): any { return this.wrapper.findComponent(SelectionDialog); } getButtonById(id: string): any { const button = this.wrapper.find(`[data-description='selection dialog ${id}']`); expect(button.exists()).toBe(true); return button; } getOkButton(): any { return this.getButtonById("ok"); } getUndoButton(): any { return this.getButtonById("undo"); } getRenderedRows(): RowElement[] { return this.getSelectionDialog().props("items") as RowElement[]; } expectAllRenderedItemsSelected() { this.getRenderedRows().forEach((item) => { expect(item._rowVariant).toBe(SELECTION_STATES.SELECTED); }); } expectNumberOfSelectedItemsToBe(number: number) { const selectedItems = this.getRenderedRows().filter((item) => item._rowVariant === SELECTION_STATES.SELECTED); expect(selectedItems.length).toBe(number); } expectOkButtonDisabled() { expect(this.getOkButton().attributes("disabled")).toBeTruthy(); } expectOkButtonEnabled() { expect(this.getOkButton().attributes("disabled")).toBeFalsy(); } expectSelectAllIconStatusToBe(status: string) { expect(this.getSelectionDialog().props("selectAllVariant")).toBe(status); } expectNoErrorMessage() { expect(this.wrapper.html()).not.toContain(someErrorText); } expectErrorMessage() { expect(this.wrapper.html()).toContain(someErrorText); } }