Unverified Commit 45fdb1de authored by Dannon's avatar Dannon Committed by GitHub
Browse files

Merge pull request #20244 from dannon/group-it-versions

[25.0] Group Tool Versions in IT Panel
parents f87df962 4eb42ae8
Loading
Loading
Loading
Loading
+218 −0
Original line number Diff line number Diff line
import { createTestingPinia } from "@pinia/testing";
import { mount } from "@vue/test-utils";
import flushPromises from "flush-promises";
import { setActivePinia } from "pinia";
import { getLocalVue } from "tests/jest/helpers";

import { useEntryPointStore } from "@/stores/entryPointStore";
import { useInteractiveToolsStore } from "@/stores/interactiveToolsStore";
import type { Tool } from "@/stores/toolStore";
import { useToolStore } from "@/stores/toolStore";
// Import the mocked function
import { filterLatestToolVersions } from "@/utils/tool-version";

import InteractiveToolsPanel from "./InteractiveToolsPanel.vue";

// Mock the tool-version utility module
jest.mock("@/utils/tool-version", () => ({
    filterLatestToolVersions: jest.fn((tools: Tool[]) => tools),
}));

const localVue = getLocalVue();

// Mock tools data
const mockTools: Partial<Tool>[] = [
    { id: "rstudio/1.1.0", version: "1.1.0", name: "RStudio", model_class: "InteractiveTool" },
    { id: "rstudio/1.2.0", version: "1.2.0", name: "RStudio", model_class: "InteractiveTool" },
    { id: "jupyter/2.0", version: "2.0", name: "Jupyter", model_class: "InteractiveTool" },
    { id: "vscode", version: "1.0", name: "VS Code", model_class: "InteractiveTool", description: "Code editor" },
];

describe("InteractiveToolsPanel component", () => {
    beforeEach(() => {
        // Reset mocks
        jest.clearAllMocks();
        (filterLatestToolVersions as jest.MockedFunction<typeof filterLatestToolVersions>).mockImplementation(
            (tools) => tools
        );
    });

    const mountComponent = async (toolsList: Partial<Tool>[] = mockTools) => {
        const pinia = createTestingPinia({
            createSpy: () => jest.fn(),
            stubActions: false,
        });
        setActivePinia(pinia);

        // Mock the stores before mounting
        const toolStore = useToolStore();
        jest.spyOn(toolStore, "fetchTools").mockImplementation(jest.fn());
        jest.spyOn(toolStore, "getInteractiveTools").mockReturnValue(toolsList as Tool[]);

        const interactiveToolsStore = useInteractiveToolsStore();
        jest.spyOn(interactiveToolsStore, "getActiveTools").mockImplementation(jest.fn());

        const entryPointStore = useEntryPointStore();
        entryPointStore.$patch({ entryPoints: [] });

        const wrapper = mount(InteractiveToolsPanel as any, {
            localVue,
            pinia,
            stubs: {
                ActivityPanel: true,
                DelayedInput: true,
                FontAwesomeIcon: true,
                UtcDate: true,
            },
            mocks: {
                $router: {
                    push: jest.fn(),
                },
            },
        });

        await flushPromises();

        return wrapper;
    };

    it("should call filterLatestToolVersions with interactive tools", async () => {
        await mountComponent();

        expect(filterLatestToolVersions).toHaveBeenCalledWith(mockTools);
    });

    it("should use the filterLatestToolVersions utility function", async () => {
        // Test that the component properly integrates with the utility function
        const filteredTools = [
            { id: "rstudio/1.2.0", version: "1.2.0", name: "RStudio", model_class: "InteractiveTool" },
            { id: "jupyter/2.0", version: "2.0", name: "Jupyter", model_class: "InteractiveTool" },
        ];
        (filterLatestToolVersions as jest.MockedFunction<typeof filterLatestToolVersions>).mockReturnValue(
            filteredTools as Tool[]
        );

        await mountComponent();

        // The mock should have been called
        expect(filterLatestToolVersions).toHaveBeenCalledWith(mockTools);
        // The mock should have returned the filtered tools
        expect(filterLatestToolVersions).toHaveReturnedWith(filteredTools);
    });

    it("should handle search functionality independently of version filtering", async () => {
        // This test verifies the component's search functionality works correctly
        // It doesn't need to re-test the filterLatestToolVersions logic
        const searchableTools = [
            { id: "rstudio/1.2.0", version: "1.2.0", name: "RStudio", model_class: "InteractiveTool" },
            {
                id: "vscode",
                version: "1.0",
                name: "VS Code",
                model_class: "InteractiveTool",
                description: "Code editor",
            },
        ];
        (filterLatestToolVersions as jest.MockedFunction<typeof filterLatestToolVersions>).mockReturnValue(
            searchableTools as Tool[]
        );

        await mountComponent();

        // The component should have called the filter function
        expect(filterLatestToolVersions).toHaveBeenCalledWith(mockTools);
    });

    it("should handle empty tool list", async () => {
        (filterLatestToolVersions as jest.MockedFunction<typeof filterLatestToolVersions>).mockReturnValue([]);

        await mountComponent([]);

        expect(filterLatestToolVersions).toHaveBeenCalledWith([]);
    });

    it("should display active interactive tools at the top", async () => {
        // Set up active tools in the entry points store
        const activeTools = [
            {
                model_class: "InteractiveToolEntryPoint" as const,
                id: "active-tool-1",
                job_id: "job-1",
                name: "Active RStudio",
                active: true,
                created_time: new Date().toISOString(),
                modified_time: new Date().toISOString(),
                output_datasets_ids: [],
                target: "http://localhost:8001/active-tool-1",
            },
            {
                model_class: "InteractiveToolEntryPoint" as const,
                id: "active-tool-2",
                job_id: "job-2",
                name: "Starting Jupyter",
                active: false,
                created_time: new Date().toISOString(),
                modified_time: new Date().toISOString(),
                output_datasets_ids: [],
                target: "http://localhost:8001/active-tool-2",
            },
        ];

        const pinia = createTestingPinia({
            createSpy: () => jest.fn(),
            stubActions: false,
        });
        setActivePinia(pinia);

        // Set up entry points before mounting
        const entryPointStore = useEntryPointStore();
        entryPointStore.entryPoints = activeTools;

        // Mock the stores
        const toolStore = useToolStore();
        jest.spyOn(toolStore, "fetchTools").mockImplementation(jest.fn());
        jest.spyOn(toolStore, "getInteractiveTools").mockReturnValue(mockTools as Tool[]);

        const interactiveToolsStore = useInteractiveToolsStore();
        jest.spyOn(interactiveToolsStore, "getActiveTools").mockImplementation(jest.fn());

        const wrapper = mount(InteractiveToolsPanel as any, {
            localVue,
            pinia,
            stubs: {
                ActivityPanel: {
                    template: '<div><slot name="header" /><slot /></div>',
                },
                DelayedInput: true,
                FontAwesomeIcon: true,
                UtcDate: true,
            },
            mocks: {
                $router: {
                    push: jest.fn(),
                },
            },
        });

        await flushPromises();

        // Check that the active tools section exists
        expect(wrapper.find(".active-tools-section").exists()).toBe(true);

        // Check that the active tools are listed
        const activeToolItems = wrapper.findAll(".active-tool-item");
        expect(activeToolItems).toHaveLength(2);

        // Check that the first active tool has the correct name
        expect(activeToolItems.at(0).text()).toContain("Active RStudio");
        expect(activeToolItems.at(0).text()).toContain("Running");

        // Check that the second active tool (starting) has the correct name
        expect(activeToolItems.at(1).text()).toContain("Starting Jupyter");
        expect(activeToolItems.at(1).text()).toContain("Starting...");

        // Check that stop buttons are present
        expect(activeToolItems.at(0).find(".btn-link.text-danger").exists()).toBe(true);
        expect(activeToolItems.at(1).find(".btn-link.text-danger").exists()).toBe(true);
    });
});
+3 −1
Original line number Diff line number Diff line
@@ -10,6 +10,7 @@ import { useEntryPointStore } from "@/stores/entryPointStore";
import { useInteractiveToolsStore } from "@/stores/interactiveToolsStore";
import type { Tool } from "@/stores/toolStore";
import { useToolStore } from "@/stores/toolStore";
import { filterLatestToolVersions } from "@/utils/tool-version";

import DelayedInput from "@/components/Common/DelayedInput.vue";
import ActivityPanel from "@/components/Panels/ActivityPanel.vue";
@@ -39,7 +40,8 @@ const filteredTools = computed(() => {

onMounted(async () => {
    await toolStore.fetchTools();
    interactiveTools.value = toolStore.getInteractiveTools();
    const allInteractiveTools = toolStore.getInteractiveTools();
    interactiveTools.value = filterLatestToolVersions(allInteractiveTools);
    loading.value = false;

    // Make sure we load active interactive tools
+128 −0
Original line number Diff line number Diff line
import type { Tool } from "@/stores/toolStore";

import { extractBaseToolId, filterLatestToolVersions } from "./tool-version";

describe("Tool Version Utilities", () => {
    describe("extractBaseToolId", () => {
        it("should handle simple tool IDs without version", () => {
            expect(extractBaseToolId("plaintools")).toBe("plaintools");
            expect(extractBaseToolId("vscode")).toBe("vscode");
        });

        it("should extract base ID from simple versioned tools", () => {
            expect(extractBaseToolId("rstudio/1.1.0")).toBe("rstudio");
            expect(extractBaseToolId("jupyter/2.0")).toBe("jupyter");
            expect(extractBaseToolId("tool/1.2.3-beta")).toBe("tool");
        });

        it("should handle tool shed tools with versions", () => {
            expect(extractBaseToolId("toolshed.g2.bx.psu.edu/repos/owner/rstudio/interactive_rstudio/1.1.0")).toBe(
                "toolshed.g2.bx.psu.edu/repos/owner/rstudio/interactive_rstudio"
            );

            expect(extractBaseToolId("toolshed.g2.bx.psu.edu/repos/devteam/bowtie2/bowtie2/2.4.2+galaxy0")).toBe(
                "toolshed.g2.bx.psu.edu/repos/devteam/bowtie2/bowtie2"
            );
        });

        it("should handle tool shed tools without versions", () => {
            expect(extractBaseToolId("toolshed.g2.bx.psu.edu/repos/owner/rstudio/interactive_rstudio")).toBe(
                "toolshed.g2.bx.psu.edu/repos/owner/rstudio/interactive_rstudio"
            );
        });

        it("should handle tools with slashes but no version", () => {
            expect(extractBaseToolId("category/subcategory/tool")).toBe("category/subcategory/tool");
            expect(extractBaseToolId("test/tool/name")).toBe("test/tool/name");
        });
    });

    describe("filterLatestToolVersions", () => {
        const createMockTool = (id: string, version: string, name = "Tool"): Tool => ({
            id,
            version,
            name,
            model_class: "InteractiveTool",
            description: "",
            labels: [],
            edam_operations: [],
            edam_topics: [],
            hidden: false,
            is_workflow_compatible: true,
            xrefs: [],
            config_file: "",
            link: "",
            min_width: 0,
            target: "",
            panel_section_id: "",
            panel_section_name: null,
            form_style: "regular",
        });

        it("should filter out older versions of tools", () => {
            const tools = [
                createMockTool("rstudio/1.1.0", "1.1.0", "RStudio"),
                createMockTool("rstudio/1.2.0", "1.2.0", "RStudio"),
                createMockTool("rstudio/1.3.1", "1.3.1", "RStudio"),
                createMockTool("jupyter/2.0", "2.0", "Jupyter"),
                createMockTool("jupyter/2.1", "2.1", "Jupyter"),
                createMockTool("vscode", "1.0", "VS Code"),
            ];

            const filtered = filterLatestToolVersions(tools);

            expect(filtered).toHaveLength(3);
            expect(filtered.find((t) => t.id.startsWith("rstudio"))?.version).toBe("1.3.1");
            expect(filtered.find((t) => t.id.startsWith("jupyter"))?.version).toBe("2.1");
            expect(filtered.find((t) => t.id === "vscode")?.version).toBe("1.0");
        });

        it("should handle tool shed tools with versioned IDs", () => {
            const tools = [
                createMockTool(
                    "toolshed.g2.bx.psu.edu/repos/owner/rstudio/interactive_rstudio/1.1.0",
                    "1.1.0",
                    "RStudio Interactive"
                ),
                createMockTool(
                    "toolshed.g2.bx.psu.edu/repos/owner/rstudio/interactive_rstudio/1.2.0",
                    "1.2.0",
                    "RStudio Interactive"
                ),
                createMockTool(
                    "toolshed.g2.bx.psu.edu/repos/owner/jupyter/interactive_jupyter/2.0",
                    "2.0",
                    "Jupyter Interactive"
                ),
            ];

            const filtered = filterLatestToolVersions(tools);

            expect(filtered).toHaveLength(2);
            const rstudio = filtered.find((t) => t.name.includes("RStudio"));
            const jupyter = filtered.find((t) => t.name.includes("Jupyter"));
            expect(rstudio?.version).toBe("1.2.0");
            expect(jupyter?.version).toBe("2.0");
        });

        it("should handle version formats with suffixes", () => {
            const tools = [
                createMockTool("tool/2.0.0", "2.0.0"),
                createMockTool("tool/2.0.0-beta", "2.0.0-beta"),
                createMockTool("tool/2.0.0-alpha", "2.0.0-alpha"),
            ];

            const filtered = filterLatestToolVersions(tools);

            expect(filtered).toHaveLength(1);
            // localeCompare with numeric option will order these as:
            // "2.0.0" < "2.0.0-alpha" < "2.0.0-beta"
            expect(filtered[0]?.version).toBe("2.0.0-beta");
        });

        it("should return empty array for empty input", () => {
            const filtered = filterLatestToolVersions([]);
            expect(filtered).toHaveLength(0);
        });
    });
});
+77 −0
Original line number Diff line number Diff line
/**
 * Utilities for handling tool versioning and lineage
 */

import type { Tool } from "@/stores/toolStore";

/**
 * Extracts the base tool ID from a versioned tool ID.
 * Handles both simple tool IDs (tool/version) and tool shed IDs
 * (toolshed.g2.bx.psu.edu/repos/owner/repo/tool/version)
 */
export function extractBaseToolId(toolId: string): string {
    let baseId = toolId;

    // Handle tool shed tools (format: toolshed.g2.bx.psu.edu/repos/owner/repo/tool_name/version)
    if (toolId.includes("/repos/")) {
        const parts = toolId.split("/");
        if (parts.length >= 5) {
            // Remove the version part if it exists
            const lastPart = parts[parts.length - 1];
            // Check if the last part looks like a version (contains dots or is numeric, optionally with suffix)
            // Now also handles + signs (e.g., "2.4.2+galaxy0")
            if (lastPart && /^\d+(\.\d+)*[-+\w]*$/.test(lastPart)) {
                baseId = parts.slice(0, -1).join("/");
            }
        }
    }
    // Handle simple versioned tools (format: tool_name/version)
    else if (toolId.includes("/")) {
        const parts = toolId.split("/");
        const lastPart = parts[parts.length - 1];
        // Check if the last part looks like a version
        if (lastPart && /^\d+(\.\d+)*[-+\w]*$/.test(lastPart)) {
            baseId = parts.slice(0, -1).join("/");
        }
    }

    return baseId;
}

/**
 * Filters a list of tools to show only the latest version from each lineage
 */
export function filterLatestToolVersions(tools: Tool[]): Tool[] {
    const versionGroups = new Map<string, Tool[]>();

    // Group tools by their base ID (without version)
    tools.forEach((tool) => {
        const baseId = extractBaseToolId(tool.id);

        if (!versionGroups.has(baseId)) {
            versionGroups.set(baseId, []);
        }
        versionGroups.get(baseId)!.push(tool);
    });

    // For each group, keep only the latest version
    const latestTools: Tool[] = [];
    versionGroups.forEach((toolGroup) => {
        if (toolGroup.length === 1 && toolGroup[0]) {
            latestTools.push(toolGroup[0]);
        } else if (toolGroup.length > 1) {
            // Sort by version (descending) and take the first one
            const sorted = toolGroup.sort((a, b) => {
                // Compare versions using natural sort
                const versionA = a.version || "0";
                const versionB = b.version || "0";
                return versionB.localeCompare(versionA, undefined, { numeric: true });
            });
            if (sorted[0]) {
                latestTools.push(sorted[0]);
            }
        }
    });

    return latestTools;
}