import { createTestingPinia } from "@pinia/testing"; import { getLocalVue } from "@tests/vitest/helpers"; import { shallowMount, type Wrapper } from "@vue/test-utils"; import flushPromises from "flush-promises"; import { PiniaVuePlugin, setActivePinia } from "pinia"; import { beforeEach, describe, expect, it, vi } from "vitest"; import invocationData from "../Workflow/test/json/invocation.json"; import WorkflowInvocationState from "./WorkflowInvocationState.vue"; const localVue = getLocalVue(); localVue.use(PiniaVuePlugin); const selectors = { invocationSummary: ".invocation-overview", bAlertStub: "balert-stub", spanElement: "span", invocationDebugTab: ".invocation-debug-tab", invocationReportTab: ".invocation-report-tab", invocationExportTab: ".invocation-export-tab", fullPageHeading: "anonymous-stub[h1='true']", }; /** Invocation data to be expected in the store */ const invocationById = { [invocationData.id]: invocationData, "not-fetched-invocation": null, "non-terminal-id": { ...invocationData, id: "non-terminal-id", state: "new", }, "non-terminal-jobs": { ...invocationData, id: "non-terminal-jobs", }, "non-terminal-populated-state": { ...invocationData, id: "non-terminal-populated-state", }, "non-terminal-error-jobs": { ...invocationData, id: "non-terminal-error-jobs", }, "terminal-error-jobs": { ...invocationData, id: "terminal-error-jobs", }, }; // Jobs summary constants const invocationDataJobsSummary = { model: "WorkflowInvocation", states: {}, populated_state: "ok", }; const invocationJobsSummaryById = { [invocationData.id]: invocationDataJobsSummary, "not-fetched-invocation": null, "non-terminal-id": invocationDataJobsSummary, "non-terminal-jobs": { ...invocationDataJobsSummary, states: { running: 1, }, }, "non-terminal-populated-state": { ...invocationDataJobsSummary, populated_state: "new", }, "non-terminal-error-jobs": { ...invocationDataJobsSummary, states: { running: 1, error: 1, }, }, "terminal-error-jobs": { ...invocationDataJobsSummary, states: { ok: 1, error: 1, }, }, }; // Mock functions that we'll export for assertions const mockFetchInvocationById = vi.fn().mockImplementation((fetchParams) => { if (fetchParams.id === "error-invocation") { throw new Error("User does not own specified item."); } }); const mockFetchInvocationJobsSummaryForId = vi.fn(); // Mock the invocation store to return the expected invocation data given the invocation ID vi.mock("@/stores/invocationStore", async () => { const originalModule = await vi.importActual("@/stores/invocationStore"); return { ...originalModule, useInvocationStore: () => ({ ...(originalModule as any).useInvocationStore(), getInvocationById: vi.fn().mockImplementation((invocationId) => { return invocationById[invocationId]; }), getInvocationJobsSummaryById: vi.fn().mockImplementation((invocationId) => { return invocationJobsSummaryById[invocationId]; }), getInvocationStepJobsSummaryById: vi.fn().mockImplementation(() => { return [ { id: "job-id", model: "Job", populated_state: "ok", states: { ok: 1, }, }, ]; }), fetchInvocationById: mockFetchInvocationById, fetchInvocationJobsSummaryForId: mockFetchInvocationJobsSummaryForId, }), }; }); // Mock the workflow store to return a workflow for `getStoredWorkflowByInstanceId` vi.mock("@/stores/workflowStore", async () => { const originalModule = await vi.importActual("@/stores/workflowStore"); return { ...originalModule, useWorkflowStore: () => ({ ...(originalModule as any).useWorkflowStore(), getStoredWorkflowByInstanceId: vi.fn().mockImplementation(() => { return { id: "workflow-id", name: "Test Workflow", version: 0, }; }), }), }; }); /** Mount the WorkflowInvocationState component with the given invocation ID * @param invocationId The invocation ID to be passed as a prop * @param shallow Whether to use shallowMount or mount * @param fullPage Whether to render the header as well or just the invocation state tabs * @returns The mounted wrapper */ async function mountWorkflowInvocationState(invocationId: string, isFullPage = false) { const pinia = createTestingPinia({ createSpy: vi.fn }); setActivePinia(pinia); const wrapper = shallowMount(WorkflowInvocationState as object, { propsData: { invocationId, isFullPage, }, pinia, localVue, }); await flushPromises(); return wrapper; } describe("WorkflowInvocationState check invocation and job terminal states", () => { beforeEach(() => { mockFetchInvocationById.mockClear(); mockFetchInvocationJobsSummaryForId.mockClear(); }); it("determines that invocation and job states are terminal with terminal invocation", async () => { const wrapper = await mountWorkflowInvocationState(invocationData.id); expect(isInvocationAndJobTerminal(wrapper)).toBe(true); // Invocation is fetched once and the jobs summary isn't fetched at all for terminal invocations assertInvocationFetched(1); assertJobsSummaryFetched(0); }); it("determines that invocation and job states are not terminal with no fetched invocation", async () => { const wrapper = await mountWorkflowInvocationState("not-fetched-invocation"); expect(isInvocationAndJobTerminal(wrapper)).toBe(false); // Invocation is fetched once and the jobs summary is then never fetched if the invocation is not in the store assertInvocationFetched(1); assertJobsSummaryFetched(0); // expect there to be an alert for the missing invocation const alert = wrapper.find(selectors.bAlertStub); expect(alert.attributes("variant")).toBe("info"); const span = alert.find(selectors.spanElement); expect(span.text()).toBe("Invocation not found."); }); it("determines that invocation is not terminal with non-terminal state", async () => { const wrapper = await mountWorkflowInvocationState("non-terminal-id"); expect(isInvocationAndJobTerminal(wrapper)).toBe(false); // Only the invocation is fetched for non-terminal invocations; once for the initial fetch and then for the polling assertInvocationFetched(2); assertJobsSummaryFetched(0); }); it("determines that job states are not terminal with non-terminal jobs but scheduled invocation", async () => { const wrapper = await mountWorkflowInvocationState("non-terminal-jobs"); expect(isInvocationAndJobTerminal(wrapper)).toBe(false); // Only the jobs summary should be polled, the invocation is initially fetched only since it is in scheduled/terminal state assertInvocationFetched(1); assertJobsSummaryFetched(1); }); it("determines that job states are not terminal with non-terminal populated state for summary", async () => { const wrapper = await mountWorkflowInvocationState("non-terminal-populated-state"); expect(isInvocationAndJobTerminal(wrapper)).toBe(false); // Only the jobs summary should be polled, the invocation is initially fetched only since it is in scheduled/terminal state assertInvocationFetched(1); assertJobsSummaryFetched(1); }); it("determines that errored invocation fetches are handled correctly", async () => { const wrapper = await mountWorkflowInvocationState("error-invocation"); expect(isInvocationAndJobTerminal(wrapper)).toBe(false); // Invocation is fetched once and the jobs summary isn't fetched at all for errored invocations assertInvocationFetched(1); assertJobsSummaryFetched(0); // expect there to be an alert for the handled error const alert = wrapper.find(selectors.bAlertStub); expect(alert.attributes("variant")).toBe("danger"); expect(alert.text()).toBe("User does not own specified item."); }); }); describe("WorkflowInvocationState check 'Report' and 'Export' tab disabled state and header", () => { it("for non-terminal invocation", async () => { const wrapper = await mountWorkflowInvocationState("non-terminal-id", true); const reportTab = wrapper.find(selectors.invocationReportTab); expect(reportTab.attributes("disabled")).toBe("true"); const exportTab = wrapper.find(selectors.invocationExportTab); expect(exportTab.attributes("disabled")).toBe("true"); }); it("for terminal invocation", async () => { const wrapper = await mountWorkflowInvocationState(invocationData.id, true); const reportTab = wrapper.find(selectors.invocationReportTab); expect(reportTab.attributes("disabled")).toBeUndefined(); const exportTab = wrapper.find(selectors.invocationExportTab); expect(exportTab.attributes("disabled")).toBeUndefined(); }); }); describe("WorkflowInvocationState check 'Debug' tab", () => { it("does not exist for non-terminal invocation", async () => { const wrapper = await mountWorkflowInvocationState("non-terminal-error-jobs", true); expect(isInvocationAndJobTerminal(wrapper)).toBe(false); expect(wrapper.find(selectors.invocationDebugTab).exists()).toBe(false); }); it("exists for terminal invocation", async () => { const wrapper = await mountWorkflowInvocationState("terminal-error-jobs", true); expect(isInvocationAndJobTerminal(wrapper)).toBe(true); expect(wrapper.find(selectors.invocationDebugTab).exists()).toBe(true); }); }); /** * This is a somewhat hacky way to determine if the invocation and job states are terminal without * exposing the internals of the component. This is just to restore the previous behavior of the test * and it only uses the wrapper to check the props of the invocation summary component. */ function isInvocationAndJobTerminal(wrapper: Wrapper): boolean { const invocationSummary = wrapper.find(selectors.invocationSummary); return invocationSummary.exists() && invocationSummary.html().includes('invocationandjobterminal="true"'); } /** Asserts that the invocation was fetched in the store the given number of times */ function assertInvocationFetched(count = 1) { expect(mockFetchInvocationById).toHaveBeenCalledTimes(count); } /** Asserts that the jobs summary was fetched in the store the given number of times */ function assertJobsSummaryFetched(count = 1) { expect(mockFetchInvocationJobsSummaryForId).toHaveBeenCalledTimes(count); }