mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-10-16 15:45:04 +08:00
Merge pull request #23463 from ahmedhamidawan/route_to_jobs_on_tool_run
This commit is contained in:
@@ -133,7 +133,7 @@ onUpdated(() => nextTick(checkClamped));
|
||||
<FontAwesomeIcon v-if="collapsed" fixed-width :icon="faAngleDoubleDown" />
|
||||
<FontAwesomeIcon v-else fixed-width :icon="faAngleDoubleUp" />
|
||||
</GButton>
|
||||
<FontAwesomeIcon v-if="props.icon" :icon="props.icon" />
|
||||
<FontAwesomeIcon v-if="props.icon" :icon="props.icon" fixed-width />
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
@@ -146,6 +146,7 @@ const isActiveHost = computed(() => props.host === activeToastHost.value);
|
||||
|
||||
.g-toast-body {
|
||||
margin-top: 0.25rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,3 +52,27 @@ export async function fetchInvocationReport(invocationId: string): Promise<Invoc
|
||||
|
||||
return data as InvocationReport;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the workflow invocation associated with a given job ID
|
||||
* @param {string} jobId The ID of the job to fetch the invocation for
|
||||
* @returns {Promise<WorkflowInvocation | null>} A promise that resolves to the workflow invocation
|
||||
* or `null` if not found (could mean that the job is not part of any workflow invocation)
|
||||
*/
|
||||
export async function fetchInvocationForJob(jobId: string): Promise<WorkflowInvocation | null> {
|
||||
const { data: invocations, error } = await GalaxyApi().GET("/api/invocations", {
|
||||
params: {
|
||||
query: { job_id: jobId },
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
rethrowSimple(error);
|
||||
}
|
||||
|
||||
if (invocations.length && invocations[0]) {
|
||||
return invocations[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -45,10 +45,14 @@ export interface JobResponse {
|
||||
outputs: {
|
||||
hid: number;
|
||||
name: string;
|
||||
history_content_type: "dataset";
|
||||
id: string;
|
||||
}[]; // TODO: This is temporary, adjust when API response is typed
|
||||
output_collections: {
|
||||
hid: number;
|
||||
name: string;
|
||||
history_content_type: "dataset_collection";
|
||||
id: string;
|
||||
}[]; // TODO: This is temporary, adjust when API response is typed
|
||||
// implicit_collections // TODO: Add when API response is typed
|
||||
errors?: any;
|
||||
@@ -56,7 +60,6 @@ export interface JobResponse {
|
||||
export interface ResponseVal {
|
||||
jobDef: JobRequest;
|
||||
jobResponse: JobResponse;
|
||||
toolName: string;
|
||||
}
|
||||
|
||||
export interface SubmitToolJobParams {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { faCheck, faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { nextTick, onUnmounted, ref, watch } from "vue";
|
||||
|
||||
import localize from "@/utils/localization";
|
||||
|
||||
import CheckDraw from "@/components/Common/SuccessIndicator/CheckDraw.vue";
|
||||
|
||||
interface Props {
|
||||
/** If the object has unsaved changes */
|
||||
hasChanges: boolean;
|
||||
@@ -22,29 +24,31 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
/** Briefly shown after a successful save when the exposed `flashSavedIndicator` method is called. */
|
||||
const showSavedIndicator = ref(false);
|
||||
|
||||
/** Used to key each individual flash so `CheckDraw` replays its draw-in animation each time. */
|
||||
const flashKey = ref(0);
|
||||
|
||||
let savedIndicatorTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let flashGeneration = 0;
|
||||
let unmounted = false;
|
||||
|
||||
onUnmounted(() => {
|
||||
unmounted = true;
|
||||
flashGeneration++;
|
||||
flashKey.value++;
|
||||
clearTimeout(savedIndicatorTimeout);
|
||||
});
|
||||
|
||||
/** Flashes a "Saved" indicator when called, given `props.hasChanges` is `false`. */
|
||||
async function flashSavedIndicator() {
|
||||
const generation = ++flashGeneration;
|
||||
const generation = ++flashKey.value;
|
||||
clearTimeout(savedIndicatorTimeout);
|
||||
|
||||
// Let a simultaneous `hasChanges` update settle before deciding whether the feedback is still valid.
|
||||
await nextTick();
|
||||
if (unmounted || generation !== flashGeneration || props.hasChanges) {
|
||||
if (unmounted || generation !== flashKey.value || props.hasChanges) {
|
||||
return;
|
||||
}
|
||||
showSavedIndicator.value = true;
|
||||
savedIndicatorTimeout = setTimeout(() => {
|
||||
if (!unmounted && generation === flashGeneration) {
|
||||
if (!unmounted && generation === flashKey.value) {
|
||||
showSavedIndicator.value = false;
|
||||
}
|
||||
}, 1500);
|
||||
@@ -56,7 +60,7 @@ watch(
|
||||
() => props.hasChanges,
|
||||
(hasChanges) => {
|
||||
if (hasChanges) {
|
||||
flashGeneration++;
|
||||
flashKey.value++;
|
||||
clearTimeout(savedIndicatorTimeout);
|
||||
showSavedIndicator.value = false;
|
||||
}
|
||||
@@ -83,7 +87,7 @@ defineExpose({
|
||||
v-show="showSavedIndicator"
|
||||
class="changes-saved-indicator"
|
||||
:data-description="`${props.objectNamespace} saved indicator`">
|
||||
<FontAwesomeIcon :icon="faCheck" fixed-width aria-hidden="true" />
|
||||
<CheckDraw v-if="showSavedIndicator" :key="flashKey" />
|
||||
{{ localize("Saved") }}
|
||||
</span>
|
||||
</transition>
|
||||
@@ -102,6 +106,9 @@ defineExpose({
|
||||
|
||||
.changes-saved-indicator {
|
||||
color: var(--color-green-700);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.saved-indicator-enter-active,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { getLocalVue } from "@tests/vitest/helpers";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import DetailBlock from "./DetailBlock.vue";
|
||||
|
||||
const localVue = getLocalVue();
|
||||
|
||||
function mountDetailBlock(props: Record<string, unknown> = {}) {
|
||||
return mount(DetailBlock as object, {
|
||||
props: { headerIcon: faInfoCircle, title: "Details", collapsible: true, ...props },
|
||||
slots: { default: "<p>content</p>" },
|
||||
global: localVue,
|
||||
});
|
||||
}
|
||||
|
||||
function isContentVisible(wrapper: ReturnType<typeof mountDetailBlock>) {
|
||||
return wrapper.findComponent({ name: "GCollapse" }).props("visible");
|
||||
}
|
||||
|
||||
async function toggle(wrapper: ReturnType<typeof mountDetailBlock>) {
|
||||
wrapper.findComponent({ name: "GHeading" }).vm.$emit("click");
|
||||
await wrapper.vm.$nextTick();
|
||||
}
|
||||
|
||||
describe("DetailBlock", () => {
|
||||
it("toggles its own collapse state when `collapsed` is omitted", async () => {
|
||||
const wrapper = mountDetailBlock();
|
||||
expect(isContentVisible(wrapper)).toBe(true);
|
||||
|
||||
await toggle(wrapper);
|
||||
|
||||
expect(isContentVisible(wrapper)).toBe(false);
|
||||
expect(wrapper.emitted("update:collapsed")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("only emits `update:collapsed` when `collapsed` is controlled by the parent", async () => {
|
||||
const wrapper = mountDetailBlock({ collapsed: false });
|
||||
|
||||
await toggle(wrapper);
|
||||
|
||||
expect(wrapper.emitted("update:collapsed")).toEqual([[true]]);
|
||||
expect(isContentVisible(wrapper)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Styled component for displaying an icon alongside a title and optional collapsible content,
|
||||
* with the icon and title placed on one line and the content placed in the same column as
|
||||
* the title. (A two-row, two-column layout with the icon and title in the first row and the
|
||||
* content in the second row.)
|
||||
*
|
||||
* The content is optionally collapsible.
|
||||
* The content by default is placed in a `GCard` but can be overloaded by providing a
|
||||
* `custom-content` slot.
|
||||
*
|
||||
* @component DetailBlock
|
||||
* @example
|
||||
* <DetailBlock :header-icon="faInfoCircle" title="Job Information" :collapsible="true">
|
||||
* <template v-slot:custom-content>
|
||||
* <p>This is the custom content for the detail block.</p>
|
||||
* </template>
|
||||
* </DetailBlock>
|
||||
*/
|
||||
|
||||
import type { IconDefinition } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
import GCollapse from "@/components/BaseComponents/GCollapse.vue";
|
||||
import GCard from "@/components/Common/GCard.vue";
|
||||
import Heading from "@/components/Common/Heading.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** The icon to display in the detail block header */
|
||||
headerIcon: IconDefinition;
|
||||
/** The title to display in the detail block header */
|
||||
title: string;
|
||||
/** If `true`, the content is collapsible */
|
||||
collapsible?: boolean;
|
||||
/** Controls collapse state externally (e.g. to persist it). Omit to manage it internally. */
|
||||
collapsed?: boolean;
|
||||
}>(),
|
||||
// `collapsed` must default to `undefined` (not Vue's implicit `false` for booleans) so that
|
||||
// omitting it actually leaves the collapse state uncontrolled.
|
||||
{ collapsed: undefined },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:collapsed", value: boolean): void;
|
||||
}>();
|
||||
|
||||
const internalCollapsed = ref(false);
|
||||
const isControlled = computed(() => props.collapsed !== undefined);
|
||||
const collapsed = computed(() => (isControlled.value ? props.collapsed! : internalCollapsed.value));
|
||||
|
||||
const collapseState = computed<"open" | "closed" | "none">(() => {
|
||||
if (!props.collapsible) {
|
||||
return "none";
|
||||
}
|
||||
return collapsed.value ? "closed" : "open";
|
||||
});
|
||||
|
||||
function toggleCollapse() {
|
||||
if (!props.collapsible) {
|
||||
return;
|
||||
}
|
||||
const next = !collapsed.value;
|
||||
if (isControlled.value) {
|
||||
emit("update:collapsed", next);
|
||||
} else {
|
||||
internalCollapsed.value = next;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="detail-block">
|
||||
<div class="detail-block-icon">
|
||||
<FontAwesomeIcon :icon="props.headerIcon" />
|
||||
</div>
|
||||
<div class="d-flex flex-gapx-1">
|
||||
<Heading
|
||||
class="flex-grow-1"
|
||||
inline
|
||||
size="sm"
|
||||
bold
|
||||
separator
|
||||
:collapse="collapseState"
|
||||
@click="toggleCollapse">
|
||||
{{ props.title }}
|
||||
</Heading>
|
||||
<slot name="header-indicators" />
|
||||
</div>
|
||||
|
||||
<div class="detail-block-spacer" />
|
||||
<GCollapse :visible="collapseState !== 'closed'">
|
||||
<slot name="custom-content">
|
||||
<GCard>
|
||||
<slot />
|
||||
</GCard>
|
||||
</slot>
|
||||
</GCollapse>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// 2x2 grid: icon | heading (row 1), empty spacer | content (row 2)
|
||||
.detail-block {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
row-gap: 0.5rem;
|
||||
column-gap: 0.5rem;
|
||||
|
||||
.detail-block-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
background: var(--color-blue-100);
|
||||
border: 1px solid var(--color-grey-200);
|
||||
border-radius: 8px;
|
||||
color: var(--color-blue-600);
|
||||
}
|
||||
|
||||
.detail-block-spacer {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
// TODO: Maybe we need to max-width all poppers to 70vw globally?
|
||||
:deep(.popper-element) {
|
||||
max-width: 70vw;
|
||||
text-align: left;
|
||||
white-space: normal !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<svg class="check-draw" viewBox="0 0 36 36" fill="none" aria-hidden="true">
|
||||
<circle class="check-draw-circle" cx="18" cy="18" r="16" pathLength="100" />
|
||||
<path class="check-draw-check" d="M10.5 18.5L15.5 23.5L25.5 12.5" pathLength="100" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.check-draw {
|
||||
width: 1.1em;
|
||||
height: 1.1em;
|
||||
}
|
||||
|
||||
// Both shapes declare pathLength="100" so this dash math is exact (100 == the whole path),
|
||||
// instead of a guessed/measured length that can undershoot and leave a stray segment visible
|
||||
// before the animation starts.
|
||||
.check-draw-circle {
|
||||
stroke: var(--color-green-700);
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
fill: none;
|
||||
stroke-dasharray: 100;
|
||||
stroke-dashoffset: 100;
|
||||
animation: check-draw-circle 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
.check-draw-check {
|
||||
stroke: var(--color-green-700);
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
fill: none;
|
||||
stroke-dasharray: 100;
|
||||
stroke-dashoffset: 100;
|
||||
animation: check-draw-check 0.3s ease-out 0.35s forwards;
|
||||
}
|
||||
|
||||
@keyframes check-draw-circle {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes check-draw-check {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import type { IconDefinition } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
import CheckDraw from "./CheckDraw.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
coveredIcon: IconDefinition;
|
||||
}>();
|
||||
|
||||
const showSuccessBadge = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
showSuccessBadge.value = true;
|
||||
setTimeout(() => {
|
||||
showSuccessBadge.value = false;
|
||||
}, 1900);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="success-icon-overlay">
|
||||
<transition name="success-icon-overlay-fade">
|
||||
<CheckDraw v-if="showSuccessBadge" class="success-icon-overlay-check-draw" />
|
||||
</transition>
|
||||
<FontAwesomeIcon
|
||||
v-if="!showSuccessBadge && props.coveredIcon"
|
||||
:icon="props.coveredIcon"
|
||||
fixed-width
|
||||
class="success-icon-overlay-covered" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.success-icon-overlay {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
}
|
||||
|
||||
.success-icon-overlay-check-draw {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
// Pixel-locked to the same box .success-icon-overlay reserves, so removing this element (when the
|
||||
// success badge fades in) never changes the wrapper's flex-content width and pushes the tool
|
||||
// name.
|
||||
.success-icon-overlay-covered {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 0.85em;
|
||||
height: 0.85em;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.success-icon-overlay-fade-leave-active {
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
|
||||
.success-icon-overlay-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -45,6 +45,8 @@ onMounted(async () => {
|
||||
appendScriptStyle(model);
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({ webhookId });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -120,7 +120,7 @@ onUnmounted(() => {
|
||||
|
||||
<JobParameters dataset_type="hda" :dataset-id="datasetId" />
|
||||
|
||||
<JobInformation :job-id="dataset.creating_job" />
|
||||
<JobInformation :job-id="dataset.creating_job" include-rerun-indicator />
|
||||
|
||||
<DatasetStorage :dataset-id="datasetId" />
|
||||
|
||||
|
||||
@@ -1,45 +1,17 @@
|
||||
<template>
|
||||
<span v-if="decoded_id">({{ decoded_id }})</span>
|
||||
</template>
|
||||
<script>
|
||||
import axios from "axios";
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
import { getGalaxyInstance } from "@/app";
|
||||
import { getAppRoot } from "@/onload/loadConfig";
|
||||
import { rethrowSimple } from "@/utils/simple-error";
|
||||
import { useUserStore } from "@/stores/userStore";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return { decoded_id: null };
|
||||
},
|
||||
computed: {
|
||||
isAdmin() {
|
||||
// window.parent.Galaxy is needed when instance is mounted in mako
|
||||
const Galaxy = getGalaxyInstance() || window.parent.Galaxy;
|
||||
return Galaxy?.user?.isAdmin() || false;
|
||||
},
|
||||
},
|
||||
created: function () {
|
||||
this.decodeId(this.id);
|
||||
},
|
||||
methods: {
|
||||
decodeId: async function (id) {
|
||||
if (this.isAdmin) {
|
||||
const url = `${getAppRoot()}api/configuration/decode/${id}`;
|
||||
try {
|
||||
const response = await axios.get(url);
|
||||
this.decoded_id = response.data.decoded_id;
|
||||
} catch (e) {
|
||||
rethrowSimple(e);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
const props = defineProps<{
|
||||
id: string;
|
||||
}>();
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const decodedId = computed<number | null>(() => userStore.getDecodedId(props.id));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span v-if="decodedId">({{ decodedId }})</span>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { computed } from "vue";
|
||||
|
||||
import { useCreatingJob } from "@/composables/useCreatingJob";
|
||||
import { useJobBasic } from "@/composables/useJobBasic";
|
||||
|
||||
import type { HistoryGraphNode } from "./historyGraphMapper";
|
||||
|
||||
@@ -10,8 +9,7 @@ import JobDetailsTabs from "./JobDetailsTabs.vue";
|
||||
import ToolExecutionJobs from "./ToolExecutionJobs.vue";
|
||||
import GAlert from "@/components/BaseComponents/GAlert.vue";
|
||||
import GTabs from "@/components/BaseComponents/GTabs.vue";
|
||||
import RerunJobButton from "@/components/JobInformation/RerunJobButton.vue";
|
||||
import JobState from "@/components/JobStates/JobState.vue";
|
||||
import JobHeader from "@/components/JobInformation/JobHeader.vue";
|
||||
import LoadingSpan from "@/components/LoadingSpan.vue";
|
||||
|
||||
interface Props {
|
||||
@@ -32,7 +30,6 @@ const infoIcon = computed(() => props.node?.icon);
|
||||
// For dataset/collection nodes, resolve the creating job and fetch its basic
|
||||
// details for the JobState badge / RerunJobButton in the GTabs nav-end.
|
||||
const { jobId: creatingJobId, loading: lookupLoading, error: lookupError } = useCreatingJob(itemId, nodeSrc);
|
||||
const { job } = useJobBasic(creatingJobId);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -46,8 +43,7 @@ const { job } = useJobBasic(creatingJobId);
|
||||
<GAlert v-else-if="isDatasetLike && lookupError" variant="info" show class="mb-0">{{ lookupError }}</GAlert>
|
||||
<GTabs v-else-if="isDatasetLike && creatingJobId">
|
||||
<template v-slot:nav-end>
|
||||
<JobState v-if="job" :job="job" class="mr-2" />
|
||||
<RerunJobButton v-if="job" :job-id="creatingJobId" outline />
|
||||
<JobHeader v-if="creatingJobId" :job-id="creatingJobId" no-tool-name no-hr />
|
||||
</template>
|
||||
<JobDetailsTabs
|
||||
:key="creatingJobId"
|
||||
|
||||
@@ -53,7 +53,7 @@ watch(
|
||||
<FontAwesomeIcon :icon="informationIcon" />
|
||||
<span class="font-weight-bold text-break">{{ informationTitle }}</span>
|
||||
</template>
|
||||
<JobInformation :key="jobId" :job-id="jobId" :include-title="false" :include-times="true" />
|
||||
<JobInformation :key="jobId" :job-id="jobId" :include-times="true" />
|
||||
</GTab>
|
||||
<GTab lazy>
|
||||
<template v-slot:title>
|
||||
|
||||
@@ -3,15 +3,12 @@ import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { BPagination } from "bootstrap-vue";
|
||||
import { computed, ref, toRef } from "vue";
|
||||
|
||||
import { useJobBasic } from "@/composables/useJobBasic";
|
||||
|
||||
import { useToolExecutionJobs } from "./useToolExecutionJobs";
|
||||
|
||||
import JobDetailsTabs from "./JobDetailsTabs.vue";
|
||||
import GAlert from "@/components/BaseComponents/GAlert.vue";
|
||||
import GTabs from "@/components/BaseComponents/GTabs.vue";
|
||||
import RerunJobButton from "@/components/JobInformation/RerunJobButton.vue";
|
||||
import JobState from "@/components/JobStates/JobState.vue";
|
||||
import JobHeader from "@/components/JobInformation/JobHeader.vue";
|
||||
import LoadingSpan from "@/components/LoadingSpan.vue";
|
||||
|
||||
interface Props {
|
||||
@@ -30,11 +27,6 @@ const { jobs, loading, error } = useToolExecutionJobs(toRef(props, "toolExecutio
|
||||
const currentIndex = ref(0);
|
||||
const currentJob = computed(() => jobs.value[currentIndex.value] ?? null);
|
||||
|
||||
// Job details for the state badge in the tab nav-end, via the shared
|
||||
// jobStore cache. The Information / Parameters / Outputs tabs each fetch
|
||||
// their own data internally via JobDetailsTabs.
|
||||
const { job } = useJobBasic(computed(() => currentJob.value?.id ?? null));
|
||||
|
||||
const hasMany = computed(() => jobs.value.length > 1);
|
||||
|
||||
// BPagination is 1-indexed; bridge to the 0-indexed currentIndex.
|
||||
@@ -53,19 +45,21 @@ const paginationPage = computed<number>({
|
||||
<template v-else-if="currentJob">
|
||||
<GTabs>
|
||||
<template v-slot:nav-end>
|
||||
<JobState v-if="job" :job="job" class="mr-2" />
|
||||
<BPagination
|
||||
v-if="hasMany"
|
||||
v-model="paginationPage"
|
||||
:total-rows="jobs.length"
|
||||
:per-page="1"
|
||||
size="sm"
|
||||
:limit="3"
|
||||
first-number
|
||||
last-number
|
||||
hide-goto-end-buttons
|
||||
class="mb-0 mr-2" />
|
||||
<RerunJobButton v-if="job" :job-id="currentJob.id" outline />
|
||||
<JobHeader v-if="currentJob" :job-id="currentJob.id" no-tool-name no-hr>
|
||||
<template v-slot:pagination>
|
||||
<BPagination
|
||||
v-if="hasMany"
|
||||
v-model="paginationPage"
|
||||
:total-rows="jobs.length"
|
||||
:per-page="1"
|
||||
size="sm"
|
||||
:limit="3"
|
||||
first-number
|
||||
last-number
|
||||
hide-goto-end-buttons
|
||||
class="mb-0 mr-2" />
|
||||
</template>
|
||||
</JobHeader>
|
||||
</template>
|
||||
<JobDetailsTabs :job-id="currentJob.id" :info-title="props.infoTitle" :info-icon="props.infoIcon" />
|
||||
</GTabs>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from "vue";
|
||||
import { computed, toRef } from "vue";
|
||||
|
||||
import { useJobStore } from "@/stores/jobStore";
|
||||
import { useJobDetails } from "@/composables/jobDetails";
|
||||
|
||||
import GAlert from "@/components/BaseComponents/GAlert.vue";
|
||||
import SwitchToHistoryLink from "@/components/History/SwitchToHistoryLink.vue";
|
||||
@@ -19,17 +19,8 @@ const emit = defineEmits<{
|
||||
(e: "dismissed"): void;
|
||||
}>();
|
||||
|
||||
const jobStore = useJobStore();
|
||||
|
||||
const historyId = computed(() => {
|
||||
return jobStore.getJob(props.jobId)?.history_id;
|
||||
});
|
||||
|
||||
async function init() {
|
||||
jobStore.fetchJob({ id: props.jobId });
|
||||
}
|
||||
|
||||
watch(() => props.jobId, init, { immediate: true });
|
||||
const { job } = useJobDetails(toRef(props, "jobId"), { poll: false });
|
||||
const historyId = computed(() => job.value?.history_id);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { getLocalVue } from "@tests/vitest/helpers";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import CodeRow from "./CodeRow.vue";
|
||||
|
||||
const localVue = getLocalVue();
|
||||
|
||||
function mountCodeRow(codeItem: string) {
|
||||
return mount(CodeRow as object, {
|
||||
props: { codeLabel: "Tool Standard Output", codeItem },
|
||||
global: { ...localVue, stubs: { HelpText: true } },
|
||||
});
|
||||
}
|
||||
|
||||
describe("CodeRow", () => {
|
||||
it("offers expanding short multi-line content, which the one-line preview folds together", async () => {
|
||||
const wrapper = mountCodeRow("first line\nsecond line\n");
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const toggle = wrapper.find(".code-row-toggle");
|
||||
expect(toggle.exists()).toBe(true);
|
||||
await toggle.trigger("click");
|
||||
expect(wrapper.find("pre").classes()).not.toContain("preview");
|
||||
});
|
||||
|
||||
it("does not offer expanding a single line that fits", async () => {
|
||||
const wrapper = mountCodeRow("one line\n");
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.find(".code-row-toggle").exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,43 +1,35 @@
|
||||
<template>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="code-row">
|
||||
<div class="code-row-label">
|
||||
<span v-if="helpUri">
|
||||
<HelpText :uri="helpUri" :text="codeLabel" />
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ codeLabel }}
|
||||
</span>
|
||||
</td>
|
||||
<td v-if="codeItem">
|
||||
<BRow align-v="center">
|
||||
<BCol cols="11">
|
||||
<pre :class="codeClass">{{ codeItem }}</pre>
|
||||
</BCol>
|
||||
<BCol
|
||||
v-g-tooltip.hover
|
||||
class="nopadding pointer"
|
||||
:title="`click to ${action}`"
|
||||
@mousedown="mouseIsDown = true"
|
||||
@mousemove="mouseIsDown ? (mouseMoved = true) : (mouseMoved = false)"
|
||||
@mouseup="toggleExpanded()">
|
||||
<FontAwesomeIcon :icon="iconClass" />
|
||||
</BCol>
|
||||
</BRow>
|
||||
</td>
|
||||
<td v-else><i>empty</i></td>
|
||||
</tr>
|
||||
</div>
|
||||
<div v-if="codeItem" class="code-row-body">
|
||||
<pre ref="pre" :class="codeClass">{{ codeItem }}</pre>
|
||||
<button
|
||||
v-if="needsToggle"
|
||||
type="button"
|
||||
class="code-row-toggle"
|
||||
:title="`click to ${action}`"
|
||||
@click="toggleExpanded">
|
||||
<FontAwesomeIcon :icon="iconClass" fixed-width />
|
||||
</button>
|
||||
</div>
|
||||
<i v-else class="code-row-empty">empty</i>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { faCompressAlt, faExpandAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { BCol, BRow } from "bootstrap-vue";
|
||||
|
||||
import HelpText from "@/components/Help/HelpText.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
BCol,
|
||||
BRow,
|
||||
FontAwesomeIcon,
|
||||
HelpText,
|
||||
},
|
||||
@@ -48,10 +40,9 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
mouseIsDown: false,
|
||||
mouseMoved: false,
|
||||
expanded: false,
|
||||
lastPos: 0,
|
||||
needsToggle: false,
|
||||
faCompressAlt,
|
||||
faExpandAlt,
|
||||
};
|
||||
@@ -78,28 +69,116 @@ export default {
|
||||
} catch {
|
||||
console.debug("Code div is not present");
|
||||
}
|
||||
this.checkNeedsToggle();
|
||||
this.observePre();
|
||||
},
|
||||
mounted() {
|
||||
this.checkNeedsToggle();
|
||||
this.resizeObserver = new ResizeObserver(() => this.checkNeedsToggle());
|
||||
this.observePre();
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.resizeObserver?.disconnect();
|
||||
},
|
||||
methods: {
|
||||
toggleExpanded() {
|
||||
this.mouseIsDown = false;
|
||||
if (this.codeItem && !this.mouseMoved) {
|
||||
if (this.codeItem) {
|
||||
this.expanded = !this.expanded;
|
||||
}
|
||||
},
|
||||
checkNeedsToggle() {
|
||||
// Only the collapsed (single-line, ellipsized) box can be reliably measured for
|
||||
// overflow — once expanded it wraps/scrolls, so skip re-checking in that state.
|
||||
if (this.expanded) {
|
||||
return;
|
||||
}
|
||||
const pre = this.$refs.pre;
|
||||
// The collapsed preview is `nowrap`, which folds line breaks into spaces, so short
|
||||
// multi-line content fits without overflowing but still needs expanding to read it.
|
||||
const isMultiline = !!this.codeItem && this.codeItem.trimEnd().includes("\n");
|
||||
this.needsToggle = !!pre && (isMultiline || pre.scrollWidth > pre.clientWidth);
|
||||
},
|
||||
observePre() {
|
||||
// .observe() on an already-observed element is a no-op, so it's safe to call this
|
||||
// on every update — it just picks up the <pre> once codeItem first becomes truthy.
|
||||
if (this.$refs.pre) {
|
||||
this.resizeObserver?.observe(this.$refs.pre);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
<style scoped lang="scss">
|
||||
.code-row {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
.nopadding {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
.code-row-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-grey-500);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.code-row-body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
background: var(--color-blue-700);
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
|
||||
.code {
|
||||
word-wrap: break-word;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.code-row-toggle {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
margin-right: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-grey-100);
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--color-grey-600);
|
||||
}
|
||||
}
|
||||
|
||||
.code-row-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
background: var(--color-grey-100);
|
||||
border: 1px dashed var(--color-grey-300);
|
||||
border-radius: 0.375rem;
|
||||
color: var(--color-grey-400);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.code {
|
||||
max-height: 50em;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
pre {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getLocalVue } from "@tests/vitest/helpers";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { JobBaseModel } from "@/api/jobs";
|
||||
|
||||
import JobDetails from "./JobDetails.vue";
|
||||
|
||||
const localVue = getLocalVue();
|
||||
|
||||
const JobInformationStub = {
|
||||
name: "JobInformation",
|
||||
template: "<div><slot name='extra-code-rows' /></div>",
|
||||
};
|
||||
|
||||
describe("JobDetails", () => {
|
||||
it("shows the traceback of a job passed in from the admin job list", () => {
|
||||
const adminListJob = { id: "job1", state: "error", traceback: "Traceback (most recent call last): ..." };
|
||||
const wrapper = mount(JobDetails as object, {
|
||||
props: { job: adminListJob as unknown as JobBaseModel },
|
||||
global: {
|
||||
...localVue,
|
||||
stubs: { JobInformation: JobInformationStub, JobParameters: true, JobMetrics: true, HelpText: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("Traceback (most recent call last): ...");
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import type { JobBaseModel } from "@/api/jobs";
|
||||
import Heading from "../Common/Heading.vue";
|
||||
import CodeRow from "./CodeRow.vue";
|
||||
import JobInformation from "./JobInformation.vue";
|
||||
import JobHeader from "@/components/JobInformation/JobHeader.vue";
|
||||
import JobMetrics from "@/components/JobMetrics/JobMetrics.vue";
|
||||
import JobParameters from "@/components/JobParameters/JobParameters.vue";
|
||||
|
||||
@@ -13,39 +14,29 @@ const props = defineProps<{
|
||||
job?: JobBaseModel;
|
||||
jobId?: string;
|
||||
invocationId?: string;
|
||||
showHeader?: boolean;
|
||||
}>();
|
||||
|
||||
const id = computed(() => props.job?.id || props.jobId);
|
||||
|
||||
// Curious as to why we're trying to access traceback, info and remote_host like this, when they don't exist on
|
||||
// `JobBaseModel`? Possibly historical reasons? (leaving as is for now)
|
||||
const traceback = computed(() => (props.job && "traceback" in props.job ? (props.job?.traceback as string) : null));
|
||||
// `traceback`, `info` and `remote_host` aren't on `JobBaseModel`: they're only present on the
|
||||
// `admin_job_list` job objects passed in by the admin jobs table. `JobInformation` can't show the
|
||||
// traceback itself since the `/api/jobs/{job_id}` response model doesn't include it.
|
||||
const traceback = computed(() => (props.job && "traceback" in props.job ? (props.job.traceback as string) : null));
|
||||
const info = computed(() => (props.job && "info" in props.job ? (props.job?.info as string) : null));
|
||||
const remoteHost = computed(() => (props.job && "remote_host" in props.job ? (props.job.remote_host as string) : null));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="id">
|
||||
<JobInformation :job-id="id" :include-times="true" :invocation-id="invocationId">
|
||||
<!-- only needed for admin job component -->
|
||||
<tr v-if="traceback">
|
||||
<td>Traceback</td>
|
||||
<td>
|
||||
<CodeRow :code-label="'Traceback'" :code-item="traceback" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="info">
|
||||
<td>Info</td>
|
||||
<td>
|
||||
<CodeRow :code-label="'Info'" :code-item="info" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="remoteHost">
|
||||
<td>Remote Host</td>
|
||||
<td>
|
||||
{{ remoteHost }}
|
||||
</td>
|
||||
</tr>
|
||||
<JobHeader v-if="props.showHeader" :job-id="id" />
|
||||
<JobInformation :job-id="id" include-times :invocation-id="invocationId">
|
||||
<template v-slot:extra-code-rows>
|
||||
<!-- only needed for admin job component -->
|
||||
<CodeRow v-if="traceback" :code-label="'Traceback'" :code-item="traceback" />
|
||||
<CodeRow v-if="info" :code-label="'Info'" :code-item="info" />
|
||||
<CodeRow v-if="remoteHost" :code-label="'Remote Host'" :code-item="remoteHost" />
|
||||
</template>
|
||||
</JobInformation>
|
||||
<br />
|
||||
<Heading id="job-parameters-heading" h1 separator inline size="md"> Job Parameters </Heading>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { faWrench } from "@fortawesome/free-solid-svg-icons";
|
||||
import { computed } from "vue";
|
||||
|
||||
import type { JobBaseModel, ShowFullJobResponse } from "@/api/jobs";
|
||||
import { useJobDetails } from "@/composables/jobDetails";
|
||||
import { useToolStore } from "@/stores/toolStore";
|
||||
|
||||
import Heading from "@/components/Common/Heading.vue";
|
||||
import SuccessIconOverlay from "@/components/Common/SuccessIndicator/SuccessIconOverlay.vue";
|
||||
import RerunJobButton from "@/components/JobInformation/RerunJobButton.vue";
|
||||
import JobState from "@/components/JobStates/JobState.vue";
|
||||
import LoadingSpan from "@/components/LoadingSpan.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
jobId: string;
|
||||
/** A job object. `undefined` fetches/polls it here instead; `null` means
|
||||
* the parent confirms there's deliberately no job yet (also skips fetching). */
|
||||
job?: JobBaseModel | ShowFullJobResponse | null;
|
||||
noToolName?: boolean;
|
||||
noHr?: boolean;
|
||||
animateSuccess?: boolean;
|
||||
}>();
|
||||
|
||||
const toolStore = useToolStore();
|
||||
|
||||
const fetchJobId = computed(() => (props.job === undefined ? props.jobId : undefined));
|
||||
const { job: fetchedJob } = useJobDetails(fetchJobId, { full: false });
|
||||
const job = computed(() => (props.job !== undefined ? props.job : fetchedJob.value));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex justify-content-between">
|
||||
<Heading
|
||||
v-if="!props.noToolName"
|
||||
class="job-header-title"
|
||||
:icon="!props.animateSuccess ? faWrench : undefined"
|
||||
inline
|
||||
size="md">
|
||||
<SuccessIconOverlay v-if="props.animateSuccess" :covered-icon="faWrench" />
|
||||
<LoadingSpan v-if="!job" message="" />
|
||||
<span v-else>{{ toolStore.getToolNameById(job.tool_id, "Job Details") }}</span>
|
||||
</Heading>
|
||||
<div class="job-header-end">
|
||||
<JobState class="job-information-state-badge" :job-id="props.jobId" :job="job" />
|
||||
<slot name="pagination" />
|
||||
<RerunJobButton :job-id="props.jobId" :job="job" outline />
|
||||
</div>
|
||||
</div>
|
||||
<hr v-if="!props.noHr" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.job-header-title {
|
||||
min-width: 0;
|
||||
flex-shrink: 1;
|
||||
|
||||
// To make it equally sized as the `covered-icon` in `SuccessIconOverlay`
|
||||
:deep(svg) {
|
||||
height: 0.85em;
|
||||
}
|
||||
}
|
||||
|
||||
.job-header-end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import CopyToClipboard from "@/components/CopyToClipboard.vue";
|
||||
import DecodedId from "@/components/DecodedId.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
/** The encoded job ID to display. */
|
||||
id: string;
|
||||
}>();
|
||||
|
||||
/** Last 8 characters of the encoded job ID. Jobs submitted together often share a leading
|
||||
* prefix, so a trailing slice is more likely to actually distinguish them at a glance. */
|
||||
const shortId = computed(() => props.id.slice(-8));
|
||||
|
||||
/** Briefly true right after `id` changes (e.g. paginating between jobs), to replay the reveal
|
||||
* animation on the short id text so the change is noticeable even if the text looks similar. */
|
||||
const justChanged = ref(false);
|
||||
let changeTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
watch(
|
||||
() => props.id,
|
||||
(id, previousId) => {
|
||||
if (!id || !previousId || id === previousId) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(changeTimeout);
|
||||
justChanged.value = true;
|
||||
changeTimeout = setTimeout(() => {
|
||||
justChanged.value = false;
|
||||
}, 900);
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span id="encoded-job-id" v-g-tooltip.hover class="job-id-chip" :title="`Job API ID: ${props.id}`">
|
||||
<span class="job-id-chip-value" :class="{ 'job-id-chip-value-reveal': justChanged }">{{ shortId }}</span>
|
||||
<DecodedId :id="props.id" />
|
||||
<CopyToClipboard message="Job API ID was copied to your clipboard" :text="props.id" title="Copy Job API ID" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.job-id-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-family: "JetBrains Mono", ui-monospace, monospace;
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
color: var(--color-blue-600);
|
||||
background: var(--color-blue-100);
|
||||
border: 1px solid var(--color-grey-200);
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.6rem;
|
||||
white-space: nowrap;
|
||||
|
||||
:deep(svg) {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-grey-500);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-blue-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.job-id-chip-value-reveal {
|
||||
display: inline-block;
|
||||
animation: job-id-chip-reveal 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes job-id-chip-reveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-0.15em);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -15,6 +15,10 @@ import JobInformation from "./JobInformation.vue";
|
||||
|
||||
vi.mock("app");
|
||||
|
||||
vi.mock("vue-router/composables", () => ({
|
||||
useRoute: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
const JOB_ID = "test_id";
|
||||
|
||||
const localVue = getLocalVue();
|
||||
@@ -75,13 +79,16 @@ describe("JobInformation/JobInformation.vue", () => {
|
||||
// table should exist
|
||||
expect(jobInfoTable).toBeTruthy();
|
||||
const rows = jobInfoTable.findAll("tr");
|
||||
// should contain 10 rows
|
||||
expect(rows.length).toBe(10);
|
||||
// should contain 7 rows
|
||||
expect(rows.length).toBe(6);
|
||||
const codeRows = wrapper.findAll(".code-row");
|
||||
// should contain 3 code rows
|
||||
expect(codeRows.length).toBe(3);
|
||||
});
|
||||
|
||||
it("stdout and stderr should be rendered", async () => {
|
||||
["stdout", "stderr"].forEach((std) => {
|
||||
const label = jobInfoTable.find("#" + std);
|
||||
const label = wrapper.find("#" + std);
|
||||
const value = label.find(".code");
|
||||
expect(value.text()).toBe(std);
|
||||
});
|
||||
@@ -116,7 +123,7 @@ describe("JobInformation/JobInformation.vue", () => {
|
||||
wrapper = mount(JobInformation, {
|
||||
propsData: { jobId: JOB_ID },
|
||||
localVue,
|
||||
pinia: createTestingPinia({ createSpy: vi.fn }),
|
||||
pinia: createTestingPinia({ createSpy: vi.fn, stubActions: false }),
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
@@ -131,11 +138,11 @@ describe("JobInformation/JobInformation.vue", () => {
|
||||
const rendered_entries = [
|
||||
{ id: "galaxy-tool-id", backend_key: "tool_id" },
|
||||
{ id: "galaxy-tool-version", backend_key: "tool_version" },
|
||||
{ id: "encoded-job-id", backend_key: "id" },
|
||||
{ id: "encoded-copied-from-job-id", backend_key: "copied_from_job_id" },
|
||||
];
|
||||
verifyValues(rendered_entries, jobInfoTable, jobResponse);
|
||||
expect(wrapper.find('td[data-description="galaxy-job-state"]').exists()).toBe(true);
|
||||
expect(wrapper.find("#encoded-job-id").text()).toContain(JOB_ID);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,7 +163,7 @@ describe("JobInformation/JobInformation.vue invocation lookup", () => {
|
||||
const wrapper = mount(JobInformation, {
|
||||
propsData: { jobId: JOB_ID },
|
||||
localVue,
|
||||
pinia: createTestingPinia({ createSpy: vi.fn }),
|
||||
pinia: createTestingPinia({ createSpy: vi.fn, stubActions: false }),
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
@@ -165,4 +172,119 @@ describe("JobInformation/JobInformation.vue invocation lookup", () => {
|
||||
"Could not determine the workflow invocation",
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores a lookup that resolves after the job changed", async () => {
|
||||
let releaseFirstLookup = () => {};
|
||||
server.use(
|
||||
http.get("/api/configuration/decode/{encoded_id}", ({ response }) => {
|
||||
return response(200).json({ decoded_id: 123 });
|
||||
}),
|
||||
http.get("/api/jobs/{job_id}", ({ response, params }) => {
|
||||
return response(200).json({ ...jobResponse, id: params.job_id });
|
||||
}),
|
||||
mswHttp.get("/api/invocations", async ({ request }) => {
|
||||
if (new URL(request.url).searchParams.get("job_id") === "first_job") {
|
||||
await new Promise((resolve) => (releaseFirstLookup = resolve));
|
||||
return HttpResponse.json([{ id: "first_job_invocation" }]);
|
||||
}
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
http.get("/api/jobs/{job_id}/console_output", ({ response }) => {
|
||||
return response(200).json({ stdout: "stdout", stderr: "stderr" });
|
||||
}),
|
||||
);
|
||||
const wrapper = mount(JobInformation, {
|
||||
propsData: { jobId: "first_job" },
|
||||
localVue,
|
||||
pinia: createTestingPinia({ createSpy: vi.fn, stubActions: false }),
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.setProps({ jobId: "second_job" });
|
||||
await flushPromises();
|
||||
releaseFirstLookup();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).not.toContain("first_job_invocation");
|
||||
expect(wrapper.find("#invocation-lookup-error").exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JobInformation/JobInformation.vue invocation lookup when returning to a job", () => {
|
||||
it("ignores the original lookup's late failure after switching away and back", async () => {
|
||||
let firstJobLookups = 0;
|
||||
let failFirstLookup = () => {};
|
||||
server.use(
|
||||
http.get("/api/configuration/decode/{encoded_id}", ({ response }) => {
|
||||
return response(200).json({ decoded_id: 123 });
|
||||
}),
|
||||
http.get("/api/jobs/{job_id}", ({ response, params }) => {
|
||||
return response(200).json({ ...jobResponse, id: params.job_id });
|
||||
}),
|
||||
mswHttp.get("/api/invocations", async ({ request }) => {
|
||||
if (new URL(request.url).searchParams.get("job_id") !== "first_job") {
|
||||
return HttpResponse.json([]);
|
||||
}
|
||||
firstJobLookups++;
|
||||
if (firstJobLookups === 1) {
|
||||
await new Promise((resolve) => (failFirstLookup = resolve));
|
||||
return HttpResponse.error();
|
||||
}
|
||||
return HttpResponse.json([{ id: "first_job_invocation" }]);
|
||||
}),
|
||||
http.get("/api/jobs/{job_id}/console_output", ({ response }) => {
|
||||
return response(200).json({ stdout: "stdout", stderr: "stderr" });
|
||||
}),
|
||||
);
|
||||
const wrapper = mount(JobInformation, {
|
||||
propsData: { jobId: "first_job" },
|
||||
localVue,
|
||||
pinia: createTestingPinia({ createSpy: vi.fn, stubActions: false }),
|
||||
});
|
||||
await flushPromises();
|
||||
await wrapper.setProps({ jobId: "second_job" });
|
||||
await flushPromises();
|
||||
await wrapper.setProps({ jobId: "first_job" });
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain("first_job_invocation");
|
||||
|
||||
failFirstLookup();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain("first_job_invocation");
|
||||
expect(wrapper.find("#invocation-lookup-error").exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JobInformation/JobInformation.vue rerun indicator", () => {
|
||||
it("fetches the job's tool to decide whether to offer a rerun", async () => {
|
||||
server.use(
|
||||
http.get("/api/configuration/decode/{encoded_id}", ({ response }) => {
|
||||
return response(200).json({ decoded_id: 123 });
|
||||
}),
|
||||
http.get("/api/jobs/{job_id}", ({ response }) => {
|
||||
return response(200).json(jobResponse);
|
||||
}),
|
||||
http.get("/api/invocations", ({ response }) => {
|
||||
return response(200).json([]);
|
||||
}),
|
||||
http.get("/api/jobs/{job_id}/console_output", ({ response }) => {
|
||||
return response(200).json({ stdout: "stdout", stderr: "stderr" });
|
||||
}),
|
||||
mswHttp.get("/api/tools/:tool_id", () =>
|
||||
HttpResponse.json({ id: jobResponse.tool_id, is_workflow_compatible: true }),
|
||||
),
|
||||
);
|
||||
const wrapper = mount(JobInformation, {
|
||||
propsData: { jobId: JOB_ID, includeRerunIndicator: true },
|
||||
localVue,
|
||||
pinia: createTestingPinia({ createSpy: vi.fn, stubActions: false }),
|
||||
});
|
||||
// The tool store fetches via axios, whose response needs more than a microtask flush
|
||||
await flushPromises();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find("[title='Rerun this job']").exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { faArrowRight, faInfoCircle, faRedo } from "@fortawesome/free-solid-svg-icons";
|
||||
import { BSkeleton } from "bootstrap-vue";
|
||||
import { computed, ref, toRef, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import { GalaxyApi } from "@/api";
|
||||
import { type JobConsoleOutput, NON_TERMINAL_STATES, type ShowFullJobResponse } from "@/api/jobs";
|
||||
import { JobConsoleOutputProvider, JobDetailsProvider } from "@/components/providers/JobProvider";
|
||||
import { errorMessageAsString, rethrowSimple } from "@/utils/simple-error";
|
||||
import { fetchInvocationForJob } from "@/api/invocations";
|
||||
import type { CardAction } from "@/components/Common/GCard.types";
|
||||
import { useJobConsoleOutput, useJobDetails } from "@/composables/jobDetails";
|
||||
import { useRerunnableTool } from "@/composables/rerunnableTool";
|
||||
import { errorMessageAsString } from "@/utils/simple-error";
|
||||
import { stateIsTerminal } from "@/utils/utils";
|
||||
|
||||
import type { JobMessage } from "../../api/jobs";
|
||||
|
||||
import Heading from "../Common/Heading.vue";
|
||||
import DecodedId from "../DecodedId.vue";
|
||||
import JobState from "../JobStates/JobState.vue";
|
||||
import CodeRow from "./CodeRow.vue";
|
||||
import RerunJobButton from "./RerunJobButton.vue";
|
||||
import JobIdChip from "./JobIdChip.vue";
|
||||
import GAlert from "@/components/BaseComponents/GAlert.vue";
|
||||
import DetailBlock from "@/components/Common/DetailBlock.vue";
|
||||
import GCard from "@/components/Common/GCard.vue";
|
||||
import CopyToClipboard from "@/components/CopyToClipboard.vue";
|
||||
import HelpText from "@/components/Help/HelpText.vue";
|
||||
import UtcDate from "@/components/UtcDate.vue";
|
||||
@@ -22,43 +28,67 @@ const props = withDefaults(
|
||||
jobId: string;
|
||||
/** If `true`, the job's update and create times, as well as time to finish are shown. */
|
||||
includeTimes?: boolean;
|
||||
/** If `true`, the title is shown. */
|
||||
includeTitle?: boolean;
|
||||
/** If `true`, the rerun indicator is shown. */
|
||||
includeRerunIndicator?: boolean;
|
||||
/** If `true`, we render a "View full details" button */
|
||||
includeViewFullDetailsButton?: boolean;
|
||||
/** If provided, this component will skip fetching the invocation ID for the job. */
|
||||
invocationId?: string;
|
||||
/** If `true`, the section is collapsible */
|
||||
collapsible?: boolean;
|
||||
/** Controls collapse state externally (e.g. a parent that also needs to persist or force
|
||||
* it open). Omit to toggle it locally, uncontrolled and unpersisted. */
|
||||
collapsed?: boolean;
|
||||
}>(),
|
||||
{ includeTitle: true },
|
||||
// `collapsed` must default to `undefined` (not Vue's implicit `false` for booleans), otherwise
|
||||
// the component always considers itself controlled and can't toggle on its own.
|
||||
{ invocationId: undefined, collapsed: undefined },
|
||||
);
|
||||
|
||||
const job = ref<ShowFullJobResponse | null>(null);
|
||||
const emit = defineEmits<{
|
||||
(e: "update:collapsed", value: boolean): void;
|
||||
}>();
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
/** Whether the collapsible job information card is collapsed. */
|
||||
const internalCollapsed = ref(false);
|
||||
const collapsed = computed({
|
||||
get: () => (props.collapsed !== undefined ? props.collapsed : internalCollapsed.value),
|
||||
set: (value) => {
|
||||
if (props.collapsed !== undefined) {
|
||||
emit("update:collapsed", value);
|
||||
} else {
|
||||
internalCollapsed.value = value;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/** Invocation ID for the run that the job might have come from. It is `null` if the job has no associated invocation,
|
||||
* or `undefined` if it has not been fetched yet.
|
||||
*/
|
||||
const fetchedInvocationId = ref<string | null | undefined>(props.invocationId);
|
||||
const invocationLookupError = ref<string | null>(null);
|
||||
|
||||
const stdout_length = ref(50000);
|
||||
const stdout_text = ref("");
|
||||
const stderr_length = ref(50000);
|
||||
const stderr_text = ref("");
|
||||
const { job, error } = useJobDetails(toRef(props, "jobId"));
|
||||
|
||||
const stdout_position = computed(() => stdout_text.value.length);
|
||||
const stderr_position = computed(() => stderr_text.value.length);
|
||||
// Only resolve the tool (possibly fetching it) when the rerun indicator is actually requested
|
||||
const { canRerun } = useRerunnableTool(computed(() => (props.includeRerunIndicator ? job.value?.tool_id : undefined)));
|
||||
|
||||
function jobStateIsTerminal(jobState: string) {
|
||||
return jobState && !NON_TERMINAL_STATES.includes(jobState);
|
||||
}
|
||||
const jobIsRunning = computed(() => job.value?.state === "running");
|
||||
|
||||
function jobStateIsRunning(jobState: string) {
|
||||
return jobState == "running";
|
||||
}
|
||||
// Console output is only polled while the job is actively running
|
||||
const consoleOutputJobId = computed(() => (jobIsRunning.value ? props.jobId : undefined));
|
||||
const { stdout: polledStdout, stderr: polledStderr } = useJobConsoleOutput(consoleOutputJobId);
|
||||
|
||||
const jobIsRunning = computed(() => (job.value?.state ? jobStateIsRunning(job.value.state) : false));
|
||||
const routeToInvocation = computed(() => `/workflows/invocations/${fetchedInvocationId.value}`);
|
||||
|
||||
/** Whether the job can be rerun; actually decided based on if the tool `is_workflow_compatible`,
|
||||
* but that would require an extra fetch. Just going by known non-rerunnable tool ids for now.
|
||||
*/
|
||||
const jobIsRerunnable = computed(
|
||||
() => !!job.value?.tool_id && !job.value.tool_id.startsWith("upload") && job.value.tool_id !== "__DATA_FETCH__",
|
||||
const stdout_text = computed(() =>
|
||||
job.value && stateIsTerminal({ state: job.value.state }) ? (job.value.tool_stdout ?? "") : polledStdout.value,
|
||||
);
|
||||
const stderr_text = computed(() =>
|
||||
job.value && stateIsTerminal({ state: job.value.state }) ? (job.value.tool_stderr ?? "") : polledStderr.value,
|
||||
);
|
||||
|
||||
const routeToInvocation = computed(() => `/workflows/invocations/${fetchedInvocationId.value}`);
|
||||
|
||||
// Curious as to why we're trying to access tool_version and traceback like this, when they don't exist on
|
||||
// `ShowFullJobResponse`? Possibly historical reasons or maybe the `JobProvider` can return different types (doesn't seem like it)?
|
||||
@@ -67,35 +97,40 @@ const toolVersion = computed(() =>
|
||||
);
|
||||
const traceback = computed(() => (job.value && "traceback" in job.value ? (job.value?.traceback as string) : null));
|
||||
|
||||
const primaryActions = computed<CardAction[]>(() => {
|
||||
const actions: CardAction[] = [];
|
||||
if (props.includeViewFullDetailsButton && route.path !== `/jobs/${props.jobId}/view`) {
|
||||
actions.push({
|
||||
id: "go-to-job-details",
|
||||
class: "text-decoration-none",
|
||||
label: "View full details",
|
||||
icon: faArrowRight,
|
||||
title: "View full job details",
|
||||
to: `/jobs/${props.jobId}/view`,
|
||||
size: "md",
|
||||
variant: "link",
|
||||
});
|
||||
}
|
||||
if (props.includeRerunIndicator && job.value && canRerun.value) {
|
||||
actions.push({
|
||||
id: "job-info-card-rerun",
|
||||
class: "text-decoration-none",
|
||||
label: "Rerun",
|
||||
icon: faRedo,
|
||||
title: "Rerun this job",
|
||||
to: `/?job_id=${props.jobId}`,
|
||||
size: "md",
|
||||
variant: "link",
|
||||
});
|
||||
}
|
||||
return actions;
|
||||
});
|
||||
|
||||
const metadataDetail = ref<Record<string, string>>({
|
||||
exit_code: `Tools may use exit codes to indicate specific execution errors. Many programs use 0 to indicate success and non-zero exit codes to indicate errors. Galaxy allows each tool to specify exit codes that indicate errors. https://docs.galaxyproject.org/en/master/dev/schema.html#tool-stdio-exit-code`,
|
||||
error_level: `NO_ERROR = 0<br>LOG = 1<br>QC = 1.1<br>WARNING = 2<br>FATAL = 3<br>FATAL_OOM = 4<br>MAX = 4`,
|
||||
});
|
||||
|
||||
function updateJob(newJob: ShowFullJobResponse) {
|
||||
job.value = newJob;
|
||||
if (jobStateIsTerminal(newJob?.state)) {
|
||||
if (newJob.tool_stdout) {
|
||||
stdout_text.value = newJob.tool_stdout;
|
||||
}
|
||||
if (newJob.tool_stderr) {
|
||||
stderr_text.value = newJob.tool_stderr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateConsoleOutputs(output: JobConsoleOutput) {
|
||||
// Keep stdout in memory and only fetch new text via JobProvider
|
||||
if (output) {
|
||||
if (output.stdout != null) {
|
||||
stdout_text.value += output.stdout;
|
||||
}
|
||||
if (output.stderr != null) {
|
||||
stderr_text.value += output.stderr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function filterMetadata(jobMessages: JobMessage[]): Partial<JobMessage>[] {
|
||||
return jobMessages.map((item) => {
|
||||
return Object.entries(item).reduce((acc: Record<string, unknown>, [key, value]) => {
|
||||
@@ -107,197 +142,279 @@ function filterMetadata(jobMessages: JobMessage[]): Partial<JobMessage>[] {
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchInvocationForJob(jobId: string) {
|
||||
const { data: invocations, error } = await GalaxyApi().GET("/api/invocations", {
|
||||
params: {
|
||||
query: { job_id: jobId },
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
rethrowSimple(error);
|
||||
}
|
||||
|
||||
if (invocations.length) {
|
||||
return invocations[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetches the invocation for the given job id to get the associated invocation id
|
||||
// Reset the fetched invocation ID as soon as the job ID changes and no invocation ID is provided
|
||||
watch(
|
||||
() => props.jobId,
|
||||
async (newId, oldId) => {
|
||||
if (
|
||||
newId &&
|
||||
(fetchedInvocationId.value === undefined || (fetchedInvocationId.value === null && newId !== oldId))
|
||||
) {
|
||||
let invocation;
|
||||
try {
|
||||
invocation = await fetchInvocationForJob(newId);
|
||||
invocationLookupError.value = null;
|
||||
} catch (e) {
|
||||
invocationLookupError.value = errorMessageAsString(e);
|
||||
(newId, oldId) => {
|
||||
if (!props.invocationId && newId !== oldId) {
|
||||
fetchedInvocationId.value = undefined;
|
||||
invocationLookupError.value = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Fetches the invocation for the given job id to get the associated invocation id. Watches whether
|
||||
// the job has loaded rather than the job object itself, which changes on every poll.
|
||||
watch(
|
||||
[() => props.jobId, () => !!job.value],
|
||||
async ([jobId, jobLoaded], _old, onCleanup) => {
|
||||
// A lookup still in flight when the job changes belongs to the old job. Comparing ids isn't
|
||||
// enough: switching away and back to the same job would accept the original lookup.
|
||||
let cancelled = false;
|
||||
onCleanup(() => (cancelled = true));
|
||||
if (!jobLoaded || !jobId || fetchedInvocationId.value !== undefined) {
|
||||
return;
|
||||
}
|
||||
let invocation;
|
||||
try {
|
||||
invocation = await fetchInvocationForJob(jobId);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
invocationLookupError.value = null;
|
||||
if (invocation) {
|
||||
fetchedInvocationId.value = invocation.id;
|
||||
} else {
|
||||
// The job does not have an associated invocation
|
||||
fetchedInvocationId.value = null;
|
||||
}
|
||||
} catch (e) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
invocationLookupError.value = errorMessageAsString(e);
|
||||
fetchedInvocationId.value = null;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Animate the skeleton -> real content swap by expanding to the content's natural height
|
||||
// (rather than popping in at full height instantly)
|
||||
function onJobInfoEnter(el: Element, done: () => void) {
|
||||
const target = el as HTMLElement;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
target.style.overflow = "hidden";
|
||||
target.style.height = "0px";
|
||||
target.style.opacity = "0";
|
||||
requestAnimationFrame(() => {
|
||||
target.style.transition = "height 0.3s ease, opacity 0.3s ease";
|
||||
target.style.height = `${target.scrollHeight}px`;
|
||||
target.style.opacity = "1";
|
||||
});
|
||||
setTimeout(done, 300);
|
||||
}
|
||||
|
||||
function onJobInfoAfterEnter(el: Element) {
|
||||
const target = el as HTMLElement;
|
||||
target.style.height = "";
|
||||
target.style.overflow = "";
|
||||
target.style.transition = "";
|
||||
}
|
||||
|
||||
// The skeleton itself never needs an animated exit as it's replaced instantly so the
|
||||
// entering content's height animation is the only motion the user sees.
|
||||
function onJobInfoLeave(el: Element, done: () => void) {
|
||||
done();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<JobDetailsProvider auto-refresh :job-id="props.jobId" @update:result="updateJob" />
|
||||
<JobConsoleOutputProvider
|
||||
v-if="jobIsRunning"
|
||||
auto-refresh
|
||||
:job-id="props.jobId"
|
||||
:stdout_position="stdout_position"
|
||||
:stdout_length="stdout_length"
|
||||
:stderr_position="stderr_position"
|
||||
:stderr_length="stderr_length"
|
||||
@update:result="updateConsoleOutputs" />
|
||||
<div v-if="props.includeTitle" class="d-flex justify-content-between flex-gapx-1">
|
||||
<Heading id="job-information-heading" class="flex-grow-1" h1 separator inline size="md">
|
||||
Job Information
|
||||
<JobState v-if="job" class="job-information-state-badge" :job="job" />
|
||||
</Heading>
|
||||
<div v-if="job && jobIsRerunnable">
|
||||
<RerunJobButton :job-id="props.jobId" outline />
|
||||
</div>
|
||||
</div>
|
||||
<table id="job-information" class="tabletip info_data_table">
|
||||
<tbody>
|
||||
<tr v-if="job && job.tool_id">
|
||||
<td>Galaxy Tool ID</td>
|
||||
<td id="galaxy-tool-id">
|
||||
{{ job.tool_id }}
|
||||
<CopyToClipboard
|
||||
message="Tool ID was copied to your clipboard"
|
||||
:text="job.tool_id"
|
||||
title="Copy Tool ID" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="job && job.state">
|
||||
<td>Job State</td>
|
||||
<td data-description="galaxy-job-state">
|
||||
<HelpText :uri="`galaxy.jobs.states.${job.state}`" :text="job.state" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="toolVersion">
|
||||
<td>Galaxy Tool Version</td>
|
||||
<td id="galaxy-tool-version">{{ toolVersion }}</td>
|
||||
</tr>
|
||||
<tr v-if="job && props.includeTimes">
|
||||
<td>Created</td>
|
||||
<td v-if="job.create_time" id="created">
|
||||
<UtcDate :date="job.create_time" mode="pretty" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="job && props.includeTimes">
|
||||
<td>Updated</td>
|
||||
<td v-if="job.update_time" id="updated">
|
||||
<UtcDate :date="job.update_time" mode="pretty" />
|
||||
</td>
|
||||
</tr>
|
||||
<CodeRow
|
||||
v-if="job && job.command_line"
|
||||
id="command-line"
|
||||
help-uri="unix.commandLine"
|
||||
:code-label="'Command Line'"
|
||||
:code-item="job.command_line" />
|
||||
<CodeRow
|
||||
v-if="job"
|
||||
id="stdout"
|
||||
help-uri="unix.stdout"
|
||||
:code-label="'Tool Standard Output'"
|
||||
:code-item="stdout_text" />
|
||||
<CodeRow
|
||||
v-if="job"
|
||||
id="stderr"
|
||||
help-uri="unix.stderr"
|
||||
:code-label="'Tool Standard Error'"
|
||||
:code-item="stderr_text" />
|
||||
<CodeRow
|
||||
v-if="traceback"
|
||||
id="traceback"
|
||||
help-uri="unix.traceback"
|
||||
:code-label="'Unexpected Job Errors'"
|
||||
:code-item="traceback" />
|
||||
<tr v-if="job">
|
||||
<td>Tool <HelpText uri="unix.exitCode" text="Exit Code" /></td>
|
||||
<td id="exit-code">{{ job.exit_code }}</td>
|
||||
</tr>
|
||||
<tr v-if="job && job.job_messages && job.job_messages.length > 0" id="job-messages">
|
||||
<td>Job Messages</td>
|
||||
<td>
|
||||
<ul v-if="Array.isArray(job.job_messages)" class="pl-2 mb-0">
|
||||
<div v-for="(message, m) in filterMetadata(job.job_messages)" :key="m" class="job-message">
|
||||
<div v-if="job.job_messages.length > 1">
|
||||
<u>Job Message {{ m + 1 }}:</u>
|
||||
</div>
|
||||
<li v-for="(value, name, i) in message" :key="i">
|
||||
<span
|
||||
v-if="metadataDetail[name]"
|
||||
v-g-tooltip.html
|
||||
class="tooltipJobInfo"
|
||||
:title="metadataDetail[name]">
|
||||
<strong>{{ name }}:</strong>
|
||||
</span>
|
||||
<strong v-else>{{ name }}:</strong>
|
||||
{{ value }}
|
||||
</li>
|
||||
<hr v-if="m + 1 < job.job_messages.length" />
|
||||
</div>
|
||||
</ul>
|
||||
<div v-else>
|
||||
{{ job.job_messages }}
|
||||
<DetailBlock
|
||||
v-model:collapsed="collapsed"
|
||||
:header-icon="faInfoCircle"
|
||||
title="Job Execution Details"
|
||||
:collapsible="props.collapsible">
|
||||
<template v-slot:header-indicators>
|
||||
<JobIdChip v-if="job" :id="job.id" />
|
||||
</template>
|
||||
<template v-slot:custom-content>
|
||||
<GCard :primary-actions="primaryActions">
|
||||
<template v-slot:description>
|
||||
<transition
|
||||
name="job-info-reveal"
|
||||
@enter="onJobInfoEnter"
|
||||
@after-enter="onJobInfoAfterEnter"
|
||||
@leave="onJobInfoLeave">
|
||||
<GAlert v-if="error" variant="danger">{{ errorMessageAsString(error) }}</GAlert>
|
||||
<div
|
||||
v-else-if="!job"
|
||||
key="skeleton"
|
||||
class="job-info-skeleton px-2 py-2"
|
||||
data-description="job information loading">
|
||||
<BSkeleton animation="wave" width="40%" />
|
||||
<BSkeleton animation="wave" width="25%" />
|
||||
<BSkeleton animation="wave" width="60%" />
|
||||
<BSkeleton animation="wave" width="90%" />
|
||||
<BSkeleton animation="wave" width="75%" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<slot></slot>
|
||||
<tr v-if="job && job.id">
|
||||
<td>Job API ID</td>
|
||||
<td id="encoded-job-id">{{ job.id }} <DecodedId :id="job.id" /></td>
|
||||
</tr>
|
||||
<tr v-if="job && job.copied_from_job_id">
|
||||
<td>Copied from Job API ID</td>
|
||||
<td id="encoded-copied-from-job-id">
|
||||
{{ job.copied_from_job_id }} <DecodedId :id="job.copied_from_job_id" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="fetchedInvocationId">
|
||||
<td>Workflow Invocation</td>
|
||||
<td>
|
||||
<router-link :to="routeToInvocation">{{ fetchedInvocationId }}</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else-if="invocationLookupError">
|
||||
<td>Workflow Invocation</td>
|
||||
<td id="invocation-lookup-error" class="text-danger">
|
||||
Could not determine the workflow invocation: {{ invocationLookupError }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else key="content">
|
||||
<table id="job-information" class="tabletip info_data_table job-info-table">
|
||||
<tbody>
|
||||
<tr v-if="job.tool_id">
|
||||
<td>Galaxy Tool ID</td>
|
||||
<td id="galaxy-tool-id">
|
||||
{{ job.tool_id }}
|
||||
<CopyToClipboard
|
||||
message="Tool ID was copied to your clipboard"
|
||||
:text="job.tool_id"
|
||||
title="Copy Tool ID" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="job.state">
|
||||
<td>Job State</td>
|
||||
<td data-description="galaxy-job-state">
|
||||
<HelpText :uri="`galaxy.jobs.states.${job.state}`" :text="job.state" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="job.exit_code !== null && job.exit_code !== undefined">
|
||||
<td>Tool <HelpText uri="unix.exitCode" text="Exit Code" /></td>
|
||||
<td id="exit-code">{{ job.exit_code }}</td>
|
||||
</tr>
|
||||
<tr v-if="toolVersion">
|
||||
<td>Galaxy Tool Version</td>
|
||||
<td id="galaxy-tool-version">{{ toolVersion }}</td>
|
||||
</tr>
|
||||
<tr v-if="props.includeTimes">
|
||||
<td>Created</td>
|
||||
<td v-if="job.create_time" id="created">
|
||||
<UtcDate :date="job.create_time" mode="pretty" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="props.includeTimes">
|
||||
<td>Updated</td>
|
||||
<td v-if="job.update_time" id="updated">
|
||||
<UtcDate :date="job.update_time" mode="pretty" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="job.job_messages && job.job_messages.length > 0" id="job-messages">
|
||||
<td>Job Messages</td>
|
||||
<td>
|
||||
<ul v-if="Array.isArray(job.job_messages)" class="pl-2 mb-0">
|
||||
<div
|
||||
v-for="(message, m) in filterMetadata(job.job_messages)"
|
||||
:key="m"
|
||||
class="job-message">
|
||||
<div v-if="job.job_messages.length > 1">
|
||||
<u>Job Message {{ m + 1 }}:</u>
|
||||
</div>
|
||||
<li v-for="(value, name, i) in message" :key="i">
|
||||
<span
|
||||
v-if="metadataDetail[name]"
|
||||
v-g-tooltip.html
|
||||
class="tooltipJobInfo"
|
||||
:title="metadataDetail[name]">
|
||||
<strong>{{ name }}:</strong>
|
||||
</span>
|
||||
<strong v-else>{{ name }}:</strong>
|
||||
{{ value }}
|
||||
</li>
|
||||
<hr v-if="m + 1 < job.job_messages.length" />
|
||||
</div>
|
||||
</ul>
|
||||
<div v-else>
|
||||
{{ job.job_messages }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<slot name="extra-table-rows" />
|
||||
|
||||
<tr v-if="job.copied_from_job_id">
|
||||
<td>Copied from Job API ID</td>
|
||||
<td id="encoded-copied-from-job-id">
|
||||
{{ job.copied_from_job_id }} <DecodedId :id="job.copied_from_job_id" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="fetchedInvocationId">
|
||||
<td>Workflow Invocation</td>
|
||||
<td>
|
||||
<router-link :to="routeToInvocation">{{ fetchedInvocationId }}</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else-if="invocationLookupError">
|
||||
<td>Workflow Invocation</td>
|
||||
<td id="invocation-lookup-error" class="text-danger">
|
||||
Could not determine the workflow invocation: {{ invocationLookupError }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="px-2 py-2">
|
||||
<CodeRow
|
||||
v-if="job.command_line"
|
||||
id="command-line"
|
||||
help-uri="unix.commandLine"
|
||||
:code-label="'Command Line'"
|
||||
:code-item="job.command_line" />
|
||||
<CodeRow
|
||||
id="stdout"
|
||||
help-uri="unix.stdout"
|
||||
:code-label="'Tool Standard Output'"
|
||||
:code-item="stdout_text" />
|
||||
<CodeRow
|
||||
id="stderr"
|
||||
help-uri="unix.stderr"
|
||||
:code-label="'Tool Standard Error'"
|
||||
:code-item="stderr_text" />
|
||||
<CodeRow
|
||||
v-if="traceback"
|
||||
id="traceback"
|
||||
help-uri="unix.traceback"
|
||||
:code-label="'Unexpected Job Errors'"
|
||||
:code-item="traceback" />
|
||||
<slot name="extra-code-rows" />
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<template v-slot:update-time>
|
||||
<i v-if="props.includeViewFullDetailsButton" class="mb-0">
|
||||
View more information on the details page.
|
||||
</i>
|
||||
</template>
|
||||
</GCard>
|
||||
</template>
|
||||
</DetailBlock>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
@import "@/style/scss/theme/blue.scss";
|
||||
|
||||
.tooltipJobInfo {
|
||||
text-decoration-line: underline;
|
||||
text-decoration-style: dashed;
|
||||
}
|
||||
|
||||
.job-information-state-badge {
|
||||
font-size: $h5-font-size;
|
||||
.job-info-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.job-info-table {
|
||||
:deep(tr) {
|
||||
td {
|
||||
padding: 0.25rem 0.5rem;
|
||||
vertical-align: top;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover td {
|
||||
background-color: var(--color-blue-100);
|
||||
}
|
||||
|
||||
td:first-child {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-grey-600);
|
||||
}
|
||||
|
||||
td:last-child {
|
||||
color: var(--color-blue-700);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { createTestingPinia } from "@pinia/testing";
|
||||
import { getLocalVue, injectTestRouter } from "@tests/vitest/helpers";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import flushPromises from "flush-promises";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useServerMock } from "@/api/client/__mocks__";
|
||||
import type { ShowFullJobResponse } from "@/api/jobs";
|
||||
import { type Tool, useToolStore } from "@/stores/toolStore";
|
||||
|
||||
import TEST_JOBS_JSON from "../WorkflowInvocationState/test/json/jobs.json";
|
||||
|
||||
import RerunJobButton from "./RerunJobButton.vue";
|
||||
|
||||
const localVue = getLocalVue();
|
||||
const router = injectTestRouter(localVue);
|
||||
const { server } = useServerMock();
|
||||
|
||||
// "sample-job-2" is terminal, so useJobDetails's polling doesn't keep a timer running past the test.
|
||||
const BASE_JOB = TEST_JOBS_JSON.find((job) => job.id === "sample-job-2") as unknown as ShowFullJobResponse;
|
||||
|
||||
const JOB_ID = BASE_JOB.id;
|
||||
const TOOL_ID = BASE_JOB.tool_id;
|
||||
|
||||
const RERUNNABLE_TOOL = { id: TOOL_ID, is_workflow_compatible: true } as Tool;
|
||||
const NON_RERUNNABLE_TOOL = { id: TOOL_ID, is_workflow_compatible: false } as Tool;
|
||||
|
||||
const SELECTORS = {
|
||||
BUTTON: ".g-button",
|
||||
SPINNER: "[data-icon='spinner']",
|
||||
};
|
||||
|
||||
function mountRerunJobButton() {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false });
|
||||
return mount(RerunJobButton as object, {
|
||||
propsData: { jobId: JOB_ID },
|
||||
localVue,
|
||||
pinia,
|
||||
router,
|
||||
});
|
||||
}
|
||||
|
||||
describe("RerunJobButton.vue", () => {
|
||||
beforeEach(() => {
|
||||
server.use(http.get("/api/jobs/:job_id", () => HttpResponse.json(BASE_JOB)));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
it("renders disabled with a spinner while the tool is loading", async () => {
|
||||
server.use(http.get("/api/tools/:tool_id", () => new Promise(() => {})));
|
||||
|
||||
const wrapper = mountRerunJobButton();
|
||||
await flushPromises();
|
||||
|
||||
const button = wrapper.find(SELECTORS.BUTTON);
|
||||
expect(button.exists()).toBe(true);
|
||||
expect(button.classes()).toContain("g-disabled");
|
||||
expect(wrapper.find(SELECTORS.SPINNER).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("enables the button once the tool is loaded and is workflow-compatible", async () => {
|
||||
server.use(http.get("/api/tools/:tool_id", () => HttpResponse.json(RERUNNABLE_TOOL)));
|
||||
|
||||
const wrapper = mountRerunJobButton();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find(SELECTORS.BUTTON).classes()).not.toContain("g-disabled");
|
||||
});
|
||||
|
||||
it("stays disabled once loaded if the tool is not workflow-compatible", async () => {
|
||||
server.use(http.get("/api/tools/:tool_id", () => HttpResponse.json(NON_RERUNNABLE_TOOL)));
|
||||
|
||||
const wrapper = mountRerunJobButton();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find(SELECTORS.BUTTON).classes()).toContain("g-disabled");
|
||||
});
|
||||
|
||||
it("settles to disabled with an explanation if the tool can't be fetched", async () => {
|
||||
server.use(
|
||||
http.get("/api/tools/:tool_id", () =>
|
||||
HttpResponse.json({ err_msg: "Tool not found", err_code: 404 }, { status: 404 }),
|
||||
),
|
||||
);
|
||||
|
||||
const wrapper = mountRerunJobButton();
|
||||
await flushPromises();
|
||||
expect(wrapper.find(SELECTORS.BUTTON).exists()).toBe(true);
|
||||
// The tool store fetches via axios, whose rejection needs more than a microtask flush
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find(SELECTORS.SPINNER).exists()).toBe(false);
|
||||
|
||||
const button = wrapper.find(SELECTORS.BUTTON);
|
||||
expect(button.classes()).toContain("g-disabled");
|
||||
expect(button.attributes("title")).toContain("Could not check if this job can be rerun");
|
||||
});
|
||||
|
||||
it("does not re-fetch the tool if it's already cached in the store", async () => {
|
||||
let toolFetchCount = 0;
|
||||
server.use(
|
||||
http.get("/api/tools/:tool_id", () => {
|
||||
toolFetchCount++;
|
||||
return HttpResponse.json(RERUNNABLE_TOOL);
|
||||
}),
|
||||
);
|
||||
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false });
|
||||
const toolStore = useToolStore();
|
||||
toolStore.fetchToolForId(TOOL_ID);
|
||||
await flushPromises();
|
||||
expect(toolFetchCount).toBe(1);
|
||||
|
||||
const wrapper = mount(RerunJobButton as object, {
|
||||
propsData: { jobId: JOB_ID },
|
||||
localVue,
|
||||
pinia,
|
||||
router,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(toolFetchCount).toBe(1);
|
||||
expect(wrapper.find(SELECTORS.BUTTON).classes()).not.toContain("g-disabled");
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { faRedo } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faRedo, faSpinner } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import type { JobBaseModel, ShowFullJobResponse } from "@/api/jobs";
|
||||
import { useJobDetails } from "@/composables/jobDetails.js";
|
||||
import { useRerunnableTool } from "@/composables/rerunnableTool";
|
||||
|
||||
import GButton from "../BaseComponents/GButton.vue";
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const props = defineProps<{
|
||||
jobId: string;
|
||||
/** A job object. `undefined` fetches/polls it here instead; `null` means
|
||||
* the parent confirms there's deliberately no job yet (also skips fetching). */
|
||||
job?: JobBaseModel | ShowFullJobResponse | null;
|
||||
outline?: boolean;
|
||||
}>();
|
||||
|
||||
const fetchJobId = computed(() => (props.job === undefined ? props.jobId : undefined));
|
||||
const { job: fetchedJob } = useJobDetails(fetchJobId, { full: false });
|
||||
const job = computed(() => (props.job !== undefined ? props.job : fetchedJob.value));
|
||||
|
||||
const rerunUrl = computed(() => `/?job_id=${props.jobId}`);
|
||||
|
||||
const {
|
||||
canRerun: canRerunJob,
|
||||
loading: toolLoading,
|
||||
error: toolError,
|
||||
} = useRerunnableTool(computed(() => job.value?.tool_id));
|
||||
|
||||
const disabledTitle = computed(() => {
|
||||
if (toolLoading.value) {
|
||||
return "Checking if this job can be rerun...";
|
||||
}
|
||||
if (toolError.value) {
|
||||
return `Could not check if this job can be rerun: ${toolError.value}`;
|
||||
}
|
||||
return "This job cannot be rerun";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<GButton
|
||||
title="Rerun job"
|
||||
v-if="job"
|
||||
title="Run Job Again"
|
||||
:disabled-title="disabledTitle"
|
||||
:disabled="toolLoading || !canRerunJob"
|
||||
size="small"
|
||||
color="blue"
|
||||
:outline="props.outline"
|
||||
:transparent="!props.outline"
|
||||
:pressed="route.fullPath === rerunUrl"
|
||||
:to="rerunUrl">
|
||||
<FontAwesomeIcon fixed-width :icon="faRedo" />
|
||||
<span>Run Job Again</span>
|
||||
<!-- Separate icons rather than a dynamic `icon` prop: an in-place patch of `FontAwesomeIcon`'s
|
||||
icon doesn't always re-render, leaving the spinner after the check settles while disabled. -->
|
||||
<FontAwesomeIcon v-if="toolLoading" fixed-width :icon="faSpinner" spin />
|
||||
<FontAwesomeIcon v-else fixed-width :icon="faRedo" />
|
||||
<span class="text-nowrap">Run again</span>
|
||||
</GButton>
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getFakeRegisteredUser } from "@tests/test-data";
|
||||
import { getLocalVue } from "@tests/vitest/helpers";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import flushPromises from "flush-promises";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { RegisteredUser } from "@/api";
|
||||
import { useServerMock } from "@/api/client/__mocks__";
|
||||
@@ -16,6 +16,8 @@ vi.mock("vue-router", () => ({
|
||||
useRoute: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
const localVue = getLocalVue();
|
||||
const { server, http } = useServerMock();
|
||||
|
||||
@@ -45,11 +47,17 @@ const SELECTORS = {
|
||||
};
|
||||
|
||||
function mountJobState(job: JobBaseModel | ShowFullJobResponse, user: RegisteredUser | null = FAKE_USER) {
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response }) => {
|
||||
return response(200).json(job as ShowFullJobResponse);
|
||||
}),
|
||||
);
|
||||
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false });
|
||||
const userStore = useUserStore();
|
||||
userStore.currentUser = user;
|
||||
return mount(JobState as object, {
|
||||
propsData: { job },
|
||||
propsData: { jobId: job.id },
|
||||
localVue,
|
||||
pinia,
|
||||
});
|
||||
@@ -64,6 +72,10 @@ describe("JobState.vue", () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
it("renders the job state badge with the job state text", async () => {
|
||||
const wrapper = mountJobState(BASE_JOB);
|
||||
await flushPromises();
|
||||
@@ -78,7 +90,7 @@ describe("JobState.vue", () => {
|
||||
|
||||
it("hides the stop button for terminal states", async () => {
|
||||
for (const state of ["ok", "error", "deleted", "failed"] as JobStateType[]) {
|
||||
const wrapper = mountJobState({ ...BASE_JOB, state });
|
||||
const wrapper = mountJobState({ ...BASE_JOB, id: `job-${state}`, state });
|
||||
await flushPromises();
|
||||
expect(wrapper.find(SELECTORS.STOP_BTN).exists()).toBe(false);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import { storeToRefs } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
import { isRegisteredUser } from "@/api";
|
||||
import { deleteJob, type JobBaseModel, NON_TERMINAL_STATES, type ShowFullJobResponse } from "@/api/jobs";
|
||||
import type { JobBaseModel, ShowFullJobResponse } from "@/api/jobs";
|
||||
import { deleteJob, NON_TERMINAL_STATES } from "@/api/jobs";
|
||||
import { useJobDetails } from "@/composables/jobDetails.js";
|
||||
import { useToast } from "@/composables/toast";
|
||||
import { getHeaderClass, iconClasses } from "@/composables/useInvocationGraph";
|
||||
import { useUserStore } from "@/stores/userStore";
|
||||
@@ -14,17 +16,27 @@ import { errorMessageAsString } from "@/utils/simple-error";
|
||||
import GButton from "../BaseComponents/GButton.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
job: JobBaseModel | ShowFullJobResponse;
|
||||
jobId: string;
|
||||
/** A job object. `undefined` fetches/polls it here instead; `null` means
|
||||
* the parent confirms there's deliberately no job yet (also skips fetching). */
|
||||
job?: JobBaseModel | ShowFullJobResponse | null;
|
||||
}>();
|
||||
|
||||
const badgeClass = computed(() => {
|
||||
if (!job.value) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
...getHeaderClass(props.job.state),
|
||||
...getHeaderClass(job.value.state),
|
||||
"text-center": true,
|
||||
};
|
||||
});
|
||||
|
||||
const stateIcon = computed(() => iconClasses[props.job.state] || null);
|
||||
const fetchJobId = computed(() => (props.job === undefined ? props.jobId : undefined));
|
||||
const { job: fetchedJob } = useJobDetails(fetchJobId, { full: false });
|
||||
const job = computed(() => (props.job !== undefined ? props.job : fetchedJob.value));
|
||||
|
||||
const stateIcon = computed(() => (job.value ? iconClasses[job.value.state] : null));
|
||||
|
||||
const Toast = useToast();
|
||||
|
||||
@@ -32,11 +44,11 @@ const { currentUser } = storeToRefs(useUserStore());
|
||||
|
||||
/** Whether the current user owns the job (can stop it) */
|
||||
const userOwnsJob = computed(() => {
|
||||
if (!currentUser.value || !isRegisteredUser(currentUser.value)) {
|
||||
if (!job.value || !currentUser.value || !isRegisteredUser(currentUser.value)) {
|
||||
return false;
|
||||
}
|
||||
if ("user_id" in props.job && props.job.user_id) {
|
||||
return props.job.user_id === currentUser.value.id;
|
||||
if ("user_id" in job.value && job.value.user_id) {
|
||||
return job.value.user_id === currentUser.value.id;
|
||||
}
|
||||
// `user_id` not available on `JobBaseModel` — caller context implies ownership
|
||||
return true;
|
||||
@@ -46,15 +58,18 @@ const userOwnsJob = computed(() => {
|
||||
* 1. If the job is not the user's own
|
||||
* 2. Job is not in a terminal state
|
||||
*/
|
||||
const canStopJob = computed(() => userOwnsJob.value && NON_TERMINAL_STATES.includes(props.job.state));
|
||||
const canStopJob = computed(() => job.value && userOwnsJob.value && NON_TERMINAL_STATES.includes(job.value.state));
|
||||
|
||||
/** Whether the stop job action is currently being performed */
|
||||
const stopping = ref(false);
|
||||
|
||||
async function stopJob() {
|
||||
if (!job.value || stopping.value) {
|
||||
return;
|
||||
}
|
||||
stopping.value = true;
|
||||
try {
|
||||
await deleteJob(props.job.id);
|
||||
await deleteJob(job.value.id);
|
||||
|
||||
Toast.success("Job scheduled to be stopped.");
|
||||
} catch (error) {
|
||||
@@ -66,7 +81,7 @@ async function stopJob() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="job-state-badge rounded px-2 py-1 text-nowrap" :class="badgeClass">
|
||||
<span v-if="job" class="job-state-badge rounded px-2 py-1 text-nowrap" :class="badgeClass">
|
||||
<FontAwesomeIcon
|
||||
v-if="stateIcon"
|
||||
:icon="stateIcon.icon"
|
||||
@@ -86,7 +101,7 @@ async function stopJob() {
|
||||
@click.stop.prevent="stopJob">
|
||||
<FontAwesomeIcon :icon="faSquare" />
|
||||
</GButton>
|
||||
{{ props.job.state }}
|
||||
{{ job.state }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -39,8 +39,10 @@ describe.each([
|
||||
async function mountElement(toolId?: string) {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn });
|
||||
// The testing store stubs fetchJob, so stand in for the job it would have loaded.
|
||||
// `useJobDetails` reads the passive `getCachedJob` getter, not `getJob` (which also
|
||||
// triggers a fetch on a cache miss).
|
||||
// @ts-expect-error: getters are only writable on a testing store
|
||||
useJobStore(pinia).getJob = () => (toolId ? ({ tool_id: toolId } as ShowFullJobResponse) : null);
|
||||
useJobStore(pinia).getCachedJob = () => (toolId ? ({ tool_id: toolId } as ShowFullJobResponse) : null);
|
||||
wrapper = mount(component as object, {
|
||||
attachTo: document.body,
|
||||
props: { jobId: "job_id", title: "Job" },
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { BCard, BCardFooter, BCardTitle } from "bootstrap-vue";
|
||||
import { computed, toRef, watch } from "vue";
|
||||
import { computed, toRef } from "vue";
|
||||
|
||||
import { useJobStore } from "@/stores/jobStore";
|
||||
import { useJobDetails } from "@/composables/jobDetails";
|
||||
|
||||
import { useMappingJobs } from "./handlesMappingJobs";
|
||||
|
||||
@@ -24,18 +24,16 @@ const props = withDefaults(defineProps<JobMetricsProps>(), {
|
||||
footer: undefined,
|
||||
});
|
||||
|
||||
const jobStore = useJobStore();
|
||||
|
||||
const toolId = computed(() => {
|
||||
if (targetJobId.value) {
|
||||
return jobStore.getJob(targetJobId.value)?.tool_id;
|
||||
return job.value?.tool_id;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const toolVersion = computed(() => {
|
||||
if (targetJobId.value) {
|
||||
// TODO: `ShowFullJobResponse` does not have a `tool_version` property
|
||||
return (jobStore.getJob(targetJobId.value) as any)?.tool_version;
|
||||
return (job.value as any)?.tool_version;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
@@ -44,19 +42,7 @@ const implicitCollectionJobsIdRef = toRef(props, "implicitCollectionJobsId");
|
||||
|
||||
const { selectJobOptions, selectedJob, targetJobId } = useMappingJobs(jobIdRef, implicitCollectionJobsIdRef);
|
||||
|
||||
async function init() {
|
||||
if (targetJobId.value) {
|
||||
jobStore.fetchJob({ id: targetJobId.value });
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
targetJobId,
|
||||
() => {
|
||||
init();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const { job } = useJobDetails(targetJobId, { poll: false });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { BCard, BCardFooter, BCardTitle } from "bootstrap-vue";
|
||||
import { computed, toRef, watch } from "vue";
|
||||
import { computed, toRef } from "vue";
|
||||
|
||||
import { useJobStore } from "@/stores/jobStore";
|
||||
import { useJobDetails } from "@/composables/jobDetails";
|
||||
|
||||
import { useMappingJobs } from "./handlesMappingJobs";
|
||||
|
||||
@@ -26,18 +26,16 @@ const props = withDefaults(defineProps<JobParametersProps>(), {
|
||||
footer: undefined,
|
||||
});
|
||||
|
||||
const jobStore = useJobStore();
|
||||
|
||||
const toolId = computed(() => {
|
||||
if (targetJobId.value) {
|
||||
return jobStore.getJob(targetJobId.value)?.tool_id;
|
||||
return job.value?.tool_id;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const toolVersion = computed(() => {
|
||||
if (targetJobId.value) {
|
||||
// TODO: `ShowFullJobResponse` does not have a `tool_version` property
|
||||
return (jobStore.getJob(targetJobId.value) as any)?.tool_version;
|
||||
return (job.value as any)?.tool_version;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
@@ -46,19 +44,7 @@ const implicitCollectionJobsIdRef = toRef(props, "implicitCollectionJobsId");
|
||||
|
||||
const { selectJobOptions, selectedJob, targetJobId } = useMappingJobs(jobIdRef, implicitCollectionJobsIdRef);
|
||||
|
||||
async function init() {
|
||||
if (targetJobId.value) {
|
||||
jobStore.fetchJob({ id: targetJobId.value });
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
targetJobId,
|
||||
() => {
|
||||
init();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const { job } = useJobDetails(targetJobId, { poll: false });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { BCard } from "bootstrap-vue";
|
||||
import { computed, toRef, watch } from "vue";
|
||||
import { computed, toRef } from "vue";
|
||||
|
||||
import { useJobStore } from "@/stores/jobStore";
|
||||
import { useJobDetails } from "@/composables/jobDetails";
|
||||
|
||||
import { useMappingJobs } from "./handlesMappingJobs";
|
||||
|
||||
@@ -19,32 +19,17 @@ const props = withDefaults(defineProps<ToolStdProps>(), {
|
||||
implicitCollectionJobsId: undefined,
|
||||
});
|
||||
|
||||
const jobStore = useJobStore();
|
||||
|
||||
const jobIdRef = toRef(props, "jobId");
|
||||
const implicitCollectionJobsIdRef = toRef(props, "implicitCollectionJobsId");
|
||||
|
||||
const { selectJobOptions, selectedJob, targetJobId } = useMappingJobs(jobIdRef, implicitCollectionJobsIdRef);
|
||||
|
||||
async function init() {
|
||||
if (targetJobId.value) {
|
||||
jobStore.fetchJob({ id: targetJobId.value });
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
targetJobId,
|
||||
() => {
|
||||
init();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const { job } = useJobDetails(targetJobId, { poll: false });
|
||||
|
||||
const jobContent = computed(() => {
|
||||
let content: string | null | undefined;
|
||||
if (targetJobId.value) {
|
||||
const job = jobStore.getJob(targetJobId.value);
|
||||
content = job && job[props.name];
|
||||
content = job.value?.[props.name];
|
||||
}
|
||||
if (!content && props.name == "tool_stdout") {
|
||||
content = "*No Standard Output Available*";
|
||||
|
||||
@@ -454,7 +454,6 @@ async function onExecute() {
|
||||
jobStore.saveLatestResponse({
|
||||
jobDef,
|
||||
jobResponse,
|
||||
toolName: toolName.value,
|
||||
});
|
||||
} else if (nErrors > 0) {
|
||||
showError.value = true;
|
||||
|
||||
@@ -1,45 +1,267 @@
|
||||
import { createTestingPinia } from "@pinia/testing";
|
||||
import { getLocalVue, injectTestRouter } from "@tests/vitest/helpers";
|
||||
import { shallowMount } from "@vue/test-utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { advanceTimersAndFlush, getLocalVue, injectTestRouter } from "@tests/vitest/helpers";
|
||||
import { enableAutoUnmount, mount, type VueWrapper } from "@vue/test-utils";
|
||||
import flushPromises from "flush-promises";
|
||||
import { setActivePinia } from "pinia";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { computed } from "vue";
|
||||
|
||||
import { useServerMock } from "@/api/client/__mocks__";
|
||||
import type { JobRequest, JobResponse, ResponseVal } from "@/api/jobs";
|
||||
import jobInformationResponse from "@/components/JobInformation/testData/jobInformationResponse.json";
|
||||
import { useConfig } from "@/composables/config";
|
||||
|
||||
import ToolSuccess from "./ToolSuccess.vue";
|
||||
import ToolSuccessMessage from "./ToolSuccessMessage.vue";
|
||||
import ToolSuccessOutputs from "./ToolSuccessOutputs.vue";
|
||||
import JobHeader from "@/components/JobInformation/JobHeader.vue";
|
||||
import JobInformation from "@/components/JobInformation/JobInformation.vue";
|
||||
import JobState from "@/components/JobStates/JobState.vue";
|
||||
import ToolRecommendation from "@/components/ToolRecommendation.vue";
|
||||
|
||||
vi.mock("@/composables/config", () => ({ useConfig: vi.fn() }));
|
||||
vi.mock("@/composables/config", () => ({
|
||||
useConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/invocations", () => ({
|
||||
fetchInvocationForJob: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("axios", () => ({
|
||||
default: {
|
||||
get: vi.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/toolStore", () => ({
|
||||
useToolStore: () => ({
|
||||
getToolNameById: () => TEST_TOOL_NAME,
|
||||
getToolForId: () => ({ is_workflow_compatible: true }),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { server, http } = useServerMock();
|
||||
enableAutoUnmount(afterEach);
|
||||
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response, params }) =>
|
||||
response(200).json({ ...jobInformationResponse, id: params.job_id, state: "ok" } as never),
|
||||
),
|
||||
http.get("/api/jobs/{job_id}/console_output", ({ response }) =>
|
||||
response(200).json({ stdout: "", stderr: "", state: "ok" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const localVue = getLocalVue();
|
||||
const router = injectTestRouter(localVue);
|
||||
|
||||
describe("ToolSuccess recommendations", () => {
|
||||
it.each([
|
||||
["anonymous", { isAnonymous: true }, true, true],
|
||||
["unloaded", null, true, true],
|
||||
["registered", { id: "user-id", email: "user@example.org" }, true, true],
|
||||
["disabled", { id: "user-id", email: "user@example.org" }, false, false],
|
||||
])("handles %s users/configuration", (_label, currentUser, enabled, expected) => {
|
||||
vi.mocked(useConfig).mockReturnValue({
|
||||
config: { value: { enable_tool_recommendations: enabled } },
|
||||
} as ReturnType<typeof useConfig>);
|
||||
const pinia = createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
initialState: {
|
||||
userStore: { currentUser },
|
||||
jobStore: {
|
||||
latestResponse: {
|
||||
jobDef: { tool_id: "cat1" },
|
||||
jobResponse: { jobs: [], outputs: [], output_collections: [] },
|
||||
toolName: "Concatenate",
|
||||
},
|
||||
vi.mocked(useConfig).mockReturnValue({
|
||||
config: computed(() => ({ enable_tool_recommendations: false })),
|
||||
isConfigLoaded: computed(() => true),
|
||||
});
|
||||
|
||||
const TEST_TOOL_NAME = "Test Tool";
|
||||
|
||||
const TEST_JOB_DEF = {
|
||||
tool_id: "test_tool",
|
||||
} as JobRequest;
|
||||
|
||||
const TEST_JOB_RESPONSE = {
|
||||
produces_entry_points: false,
|
||||
jobs: [jobInformationResponse],
|
||||
outputs: [],
|
||||
output_collections: [],
|
||||
} as unknown as JobResponse;
|
||||
|
||||
// Selectors
|
||||
const SELECTORS = {
|
||||
PAGINATION_ITEM: ".page-item .page-link",
|
||||
};
|
||||
|
||||
async function mountToolSuccess(latestResponse: ResponseVal | null, currentUser: unknown = null) {
|
||||
const testPinia = createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
stubActions: false,
|
||||
initialState: {
|
||||
jobStore: {
|
||||
latestResponse,
|
||||
},
|
||||
userStore: {
|
||||
currentUser,
|
||||
},
|
||||
},
|
||||
});
|
||||
setActivePinia(testPinia);
|
||||
|
||||
const wrapper = mount(ToolSuccess as object, {
|
||||
localVue,
|
||||
router,
|
||||
pinia: testPinia,
|
||||
stubs: {
|
||||
FontAwesomeIcon: true,
|
||||
},
|
||||
}) as VueWrapper;
|
||||
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
describe("ToolSuccess", () => {
|
||||
it("redirects home when there is no response in the store", async () => {
|
||||
const routerPush = vi.spyOn(router, "push");
|
||||
await mountToolSuccess(null);
|
||||
expect(routerPush).toHaveBeenCalledWith("/");
|
||||
});
|
||||
|
||||
describe("ToolSuccess recommendations", () => {
|
||||
it.each([
|
||||
["anonymous", { isAnonymous: true }, true, true],
|
||||
["unloaded", null, true, true],
|
||||
["registered", { id: "user-id", email: "user@example.org" }, true, true],
|
||||
["disabled", { id: "user-id", email: "user@example.org" }, false, false],
|
||||
])("handles %s users/configuration", async (_label, currentUser, enabled, expected) => {
|
||||
vi.mocked(useConfig).mockReturnValue({
|
||||
config: { value: { enable_tool_recommendations: enabled } },
|
||||
} as ReturnType<typeof useConfig>);
|
||||
const wrapper = await mountToolSuccess(
|
||||
{
|
||||
jobDef: TEST_JOB_DEF,
|
||||
jobResponse: TEST_JOB_RESPONSE,
|
||||
},
|
||||
currentUser,
|
||||
);
|
||||
expect(wrapper.findComponent(ToolRecommendation).exists()).toBe(expected);
|
||||
expect(wrapper.findComponent(JobInformation).exists()).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("with a single job", () => {
|
||||
let wrapper: VueWrapper;
|
||||
|
||||
beforeEach(async () => {
|
||||
wrapper = await mountToolSuccess({
|
||||
jobDef: TEST_JOB_DEF,
|
||||
jobResponse: TEST_JOB_RESPONSE,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders JobHeader for the job with tool name", () => {
|
||||
const jobHeader = wrapper.findComponent(JobHeader);
|
||||
expect(jobHeader.text()).toContain(TEST_TOOL_NAME);
|
||||
expect(jobHeader.props("jobId")).toEqual(jobInformationResponse.id);
|
||||
});
|
||||
|
||||
it("does not show the multi-job pagination header", () => {
|
||||
expect(wrapper.findAll(SELECTORS.PAGINATION_ITEM).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with outputs", () => {
|
||||
const TEST_OUTPUT = { id: "output_id", hid: 1, name: "output1", history_content_type: "dataset" };
|
||||
const TEST_OUTPUT_COLLECTION = {
|
||||
id: "collection_id",
|
||||
hid: 2,
|
||||
name: "collection1",
|
||||
history_content_type: "dataset_collection",
|
||||
};
|
||||
|
||||
it("passes both dataset and collection outputs through to ToolSuccessOutputs", async () => {
|
||||
const wrapper = await mountToolSuccess({
|
||||
jobDef: TEST_JOB_DEF,
|
||||
jobResponse: {
|
||||
...TEST_JOB_RESPONSE,
|
||||
outputs: [TEST_OUTPUT],
|
||||
output_collections: [TEST_OUTPUT_COLLECTION],
|
||||
} as unknown as JobResponse,
|
||||
});
|
||||
|
||||
const outputs = wrapper.findComponent(ToolSuccessOutputs);
|
||||
expect(outputs.exists()).toBe(true);
|
||||
expect(outputs.props("jobResponse").outputs).toEqual([TEST_OUTPUT]);
|
||||
expect(outputs.props("jobResponse").output_collections).toEqual([TEST_OUTPUT_COLLECTION]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with multiple jobs", () => {
|
||||
const SECOND_JOB = { ...jobInformationResponse, id: "test_id_2" };
|
||||
let wrapper: VueWrapper;
|
||||
|
||||
beforeEach(async () => {
|
||||
wrapper = await mountToolSuccess({
|
||||
jobDef: TEST_JOB_DEF,
|
||||
jobResponse: {
|
||||
...TEST_JOB_RESPONSE,
|
||||
jobs: [jobInformationResponse, SECOND_JOB] as unknown as JobResponse["jobs"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a pagination control for each job", () => {
|
||||
expect(wrapper.findAll(SELECTORS.PAGINATION_ITEM).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows JobState and RerunJobButton for the currently viewed job", () => {
|
||||
const jobState = wrapper.findComponent(JobState);
|
||||
expect(jobState.exists()).toBe(true);
|
||||
expect(jobState.props("jobId")).toEqual(jobInformationResponse.id);
|
||||
});
|
||||
|
||||
it("switches the viewed job when navigating to the next pagination page", async () => {
|
||||
const pageTwo = wrapper
|
||||
.findAll(SELECTORS.PAGINATION_ITEM)
|
||||
.filter((link) => link.text() === "2")
|
||||
.at(0);
|
||||
await pageTwo?.trigger("click");
|
||||
|
||||
const jobState = wrapper.findComponent(JobState);
|
||||
expect(jobState.props("jobId")).toEqual(SECOND_JOB.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolSuccess live job state", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shares one poll between the header, details and entry points, and stops polling the previous page", async () => {
|
||||
const calls: string[] = [];
|
||||
let state = "running";
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response, params }) => {
|
||||
calls.push(params.job_id as string);
|
||||
return response(200).json({ ...jobInformationResponse, id: params.job_id, state } as never);
|
||||
}),
|
||||
);
|
||||
const firstId = jobInformationResponse.id;
|
||||
const secondId = "second-job";
|
||||
const wrapper = await mountToolSuccess({
|
||||
jobDef: TEST_JOB_DEF,
|
||||
jobResponse: {
|
||||
...TEST_JOB_RESPONSE,
|
||||
produces_entry_points: true,
|
||||
jobs: [TEST_JOB_RESPONSE.jobs[0]!, { ...TEST_JOB_RESPONSE.jobs[0]!, id: secondId }],
|
||||
},
|
||||
});
|
||||
const wrapper = shallowMount(ToolSuccess, { localVue, router, pinia });
|
||||
expect(wrapper.findComponent(ToolRecommendation).exists()).toBe(expected);
|
||||
expect(wrapper.findComponent(ToolSuccessMessage).exists()).toBe(true);
|
||||
expect(wrapper.findComponent(JobState).text()).toContain("running");
|
||||
expect(calls).toEqual([firstId]);
|
||||
const pageTwo = wrapper.findAll(SELECTORS.PAGINATION_ITEM).find((link) => link.text() === "2");
|
||||
expect(pageTwo).toBeDefined();
|
||||
await pageTwo!.trigger("click");
|
||||
await flushPromises();
|
||||
expect(calls).toEqual([firstId, secondId]);
|
||||
state = "ok";
|
||||
await advanceTimersAndFlush(1000);
|
||||
expect(calls).toEqual([firstId, secondId, secondId]);
|
||||
expect(wrapper.findComponent(JobState).text()).toContain("ok");
|
||||
expect(wrapper.find('[data-description="galaxy-job-state"]').text()).toContain("ok");
|
||||
expect(wrapper.text()).toContain("No Interactive Tool sessions are currently available");
|
||||
await advanceTimersAndFlush(3000);
|
||||
expect(calls).toEqual([firstId, secondId, secondId]);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { faLightbulb } from "@fortawesome/free-solid-svg-icons";
|
||||
import { BPagination } from "bootstrap-vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
import { useConfig } from "@/composables/config";
|
||||
import { useJobDetails } from "@/composables/jobDetails";
|
||||
import { useUserLocalStorage } from "@/composables/userLocalStorage";
|
||||
import { useJobStore } from "@/stores/jobStore";
|
||||
|
||||
import LoadingSpan from "../LoadingSpan.vue";
|
||||
import ToolRecommendation from "../ToolRecommendation.vue";
|
||||
import ToolSuccessMessage from "./ToolSuccessMessage.vue";
|
||||
import GAlert from "@/components/BaseComponents/GAlert.vue";
|
||||
import DetailBlock from "@/components/Common/DetailBlock.vue";
|
||||
import Webhook from "@/components/Common/Webhook.vue";
|
||||
import JobHeader from "@/components/JobInformation/JobHeader.vue";
|
||||
import JobInformation from "@/components/JobInformation/JobInformation.vue";
|
||||
import ToolSuccessOutputs from "@/components/Tool/ToolSuccessOutputs.vue";
|
||||
import ToolSuccessToc from "@/components/Tool/ToolSuccessToc.vue";
|
||||
import ToolEntryPoints from "@/components/ToolEntryPoints/ToolEntryPoints.vue";
|
||||
|
||||
const { config } = useConfig();
|
||||
@@ -21,26 +29,93 @@ const router = useRouter();
|
||||
const jobDef = computed(() => latestResponse.value?.jobDef);
|
||||
const jobResponse = computed(() => latestResponse.value?.jobResponse);
|
||||
const showRecommendation = computed(() => config.value.enable_tool_recommendations);
|
||||
const toolName = computed(() => latestResponse.value?.toolName);
|
||||
const nJobs = computed(() => (jobResponse.value && jobResponse.value.jobs ? jobResponse.value.jobs.length : 0));
|
||||
|
||||
// no data means that no tool was run in this session i.e. no data in the store
|
||||
if (!latestResponse.value || Object.keys(latestResponse.value).length === 0) {
|
||||
router.push(`/`);
|
||||
}
|
||||
|
||||
const currentIndex = ref(0);
|
||||
// BPagination is 1-indexed; bridge to the 0-indexed currentIndex.
|
||||
const paginationPage = computed<number>({
|
||||
get: () => currentIndex.value + 1,
|
||||
set: (val: number) => {
|
||||
currentIndex.value = val - 1;
|
||||
},
|
||||
});
|
||||
|
||||
watch(jobResponse, () => {
|
||||
currentIndex.value = 0;
|
||||
});
|
||||
|
||||
const viewedJobId = computed(
|
||||
() => jobResponse.value?.jobs?.[currentIndex.value]?.id ?? jobResponse.value?.jobs?.[0]?.id,
|
||||
);
|
||||
const { job: viewedJob } = useJobDetails(viewedJobId);
|
||||
|
||||
const webhook = ref<InstanceType<typeof Webhook> | null>(null);
|
||||
const webhookId = computed(() => webhook.value?.webhookId ?? null);
|
||||
|
||||
const jobInformationCollapsed = useUserLocalStorage("job-information-collapsed", false);
|
||||
const toolSuccessOutputsCollapsed = useUserLocalStorage("tool-success-outputs-collapsed", false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<GAlert v-if="!jobResponse" variant="info" show>
|
||||
<LoadingSpan message="Waiting on data" />
|
||||
</GAlert>
|
||||
<div v-else>
|
||||
<div v-if="jobResponse?.produces_entry_points">
|
||||
<ToolEntryPoints v-for="job in jobResponse.jobs" :key="job.id" :job-id="job.id" />
|
||||
<GAlert v-if="!jobResponse">
|
||||
<LoadingSpan message="Waiting on data" />
|
||||
</GAlert>
|
||||
<div v-else>
|
||||
<template v-if="viewedJobId">
|
||||
<JobHeader :job-id="viewedJobId" :job="viewedJob" animate-success>
|
||||
<template v-slot:pagination>
|
||||
<BPagination
|
||||
v-if="nJobs > 1"
|
||||
v-model="paginationPage"
|
||||
:total-rows="nJobs"
|
||||
:per-page="1"
|
||||
size="sm"
|
||||
:limit="3"
|
||||
first-number
|
||||
last-number
|
||||
hide-goto-end-buttons
|
||||
class="mb-0 unselectable" />
|
||||
</template>
|
||||
</JobHeader>
|
||||
|
||||
<ToolSuccessToc
|
||||
v-model:job-information-collapsed="jobInformationCollapsed"
|
||||
v-model:tool-success-outputs-collapsed="toolSuccessOutputsCollapsed"
|
||||
:job-response="jobResponse"
|
||||
:job-def="jobDef"
|
||||
:webhook-id="webhookId"
|
||||
:show-recommendation="showRecommendation" />
|
||||
|
||||
<div v-if="jobResponse.produces_entry_points" id="tool-entry-points">
|
||||
<ToolEntryPoints :job-id="viewedJobId" :job="viewedJob" :other-jobs-count="nJobs > 1 ? nJobs - 1 : 0" />
|
||||
</div>
|
||||
<ToolSuccessMessage :job-response="jobResponse" :tool-name="toolName || '...'" />
|
||||
<Webhook v-if="jobDef" type="tool" :tool-id="jobDef.tool_id || undefined" />
|
||||
<ToolRecommendation v-if="showRecommendation && jobDef?.tool_id" :tool-id="jobDef.tool_id" />
|
||||
|
||||
<div id="job-execution-details">
|
||||
<JobInformation
|
||||
v-model:collapsed="jobInformationCollapsed"
|
||||
:job-id="viewedJobId"
|
||||
collapsible
|
||||
include-view-full-details-button />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div id="tool-run-outputs">
|
||||
<ToolSuccessOutputs v-model:collapsed="toolSuccessOutputsCollapsed" :job-response="jobResponse" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="tool-success-webhook">
|
||||
<DetailBlock v-if="jobDef" v-show="webhookId" :header-icon="faLightbulb" title="Before You Go">
|
||||
<Webhook ref="webhook" type="tool" :tool-id="jobDef.tool_id || undefined" />
|
||||
</DetailBlock>
|
||||
</div>
|
||||
|
||||
<div v-if="showRecommendation && jobDef?.tool_id" id="tool-recommendation">
|
||||
<ToolRecommendation :tool-id="jobDef.tool_id" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { getLocalVue, injectTestRouter } from "@tests/vitest/helpers";
|
||||
import { mount, type VueWrapper } from "@vue/test-utils";
|
||||
import flushPromises from "flush-promises";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import jobInformationResponse from "@/components/JobInformation/testData/jobInformationResponse.json";
|
||||
|
||||
import ToolSuccessMessage from "./ToolSuccessMessage.vue";
|
||||
|
||||
const localVue = getLocalVue();
|
||||
const router = injectTestRouter(localVue);
|
||||
|
||||
// Prop constants
|
||||
const TEST_JOB_RESPONSE = {
|
||||
produces_entry_points: false,
|
||||
jobs: [jobInformationResponse],
|
||||
outputs: [
|
||||
{
|
||||
hid: 0,
|
||||
name: "output1",
|
||||
},
|
||||
],
|
||||
output_collections: [
|
||||
{
|
||||
hid: 1,
|
||||
name: "collection1",
|
||||
},
|
||||
],
|
||||
};
|
||||
const TEST_TOOL_NAME = "Test Tool";
|
||||
|
||||
// Selectors
|
||||
const SELECTORS = {
|
||||
SINGULAR_JOB_LINK: "[data-description='singular job link']",
|
||||
JOB_LINK: "[data-description='job link']",
|
||||
OUTPUTS_LIST: "[data-description='list of outputs']",
|
||||
};
|
||||
|
||||
describe("ToolSuccessMessage", () => {
|
||||
let wrapper: VueWrapper;
|
||||
|
||||
beforeEach(async () => {
|
||||
wrapper = mount(ToolSuccessMessage as object, {
|
||||
global: localVue,
|
||||
router,
|
||||
props: {
|
||||
jobResponse: TEST_JOB_RESPONSE,
|
||||
toolName: TEST_TOOL_NAME,
|
||||
},
|
||||
stubs: {
|
||||
FontAwesomeIcon: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("shows both dataset and collection outputs correctly", async () => {
|
||||
expect(wrapper.find(SELECTORS.OUTPUTS_LIST).text()).toContain(
|
||||
`${TEST_JOB_RESPONSE.outputs[0]?.hid}: ${TEST_JOB_RESPONSE.outputs[0]?.name}`,
|
||||
);
|
||||
expect(wrapper.find(SELECTORS.OUTPUTS_LIST).text()).toContain(
|
||||
`${TEST_JOB_RESPONSE.output_collections[0]?.hid}: ${TEST_JOB_RESPONSE.output_collections[0]?.name}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("has a link to the singular job", () => {
|
||||
expect(wrapper.find(SELECTORS.SINGULAR_JOB_LINK).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows the tool name", () => {
|
||||
expect(wrapper.text()).toContain(`Started tool ${TEST_TOOL_NAME}`);
|
||||
});
|
||||
|
||||
it("has a link to each job when multiple jobs are present", async () => {
|
||||
// Remount with two-job props instead of setProps -- VTU v2 + Vue 3 doesn't
|
||||
// reliably re-render when a top-level prop object is replaced wholesale.
|
||||
wrapper = mount(ToolSuccessMessage as object, {
|
||||
global: localVue,
|
||||
router,
|
||||
props: {
|
||||
jobResponse: {
|
||||
...TEST_JOB_RESPONSE,
|
||||
jobs: [
|
||||
jobInformationResponse,
|
||||
{
|
||||
...jobInformationResponse,
|
||||
id: "test_id_2",
|
||||
},
|
||||
],
|
||||
},
|
||||
toolName: TEST_TOOL_NAME,
|
||||
},
|
||||
stubs: {
|
||||
FontAwesomeIcon: true,
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
expect(wrapper.findAll(SELECTORS.JOB_LINK).length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
|
||||
import type { JobResponse } from "@/api/jobs";
|
||||
|
||||
import ExternalLink from "../ExternalLink.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
jobResponse: JobResponse;
|
||||
toolName: string;
|
||||
}>();
|
||||
|
||||
const outputs = computed(() => props.jobResponse.outputs.concat(props.jobResponse.output_collections));
|
||||
|
||||
const nJobs = computed(() => (props.jobResponse && props.jobResponse.jobs ? props.jobResponse.jobs.length : 0));
|
||||
|
||||
const nJobsText = computed(() => (nJobs.value > 1 ? `${nJobs.value} jobs` : `1 job`));
|
||||
|
||||
const nOutputsText = computed(() => (outputs.value.length > 1 ? `${outputs.value.length} outputs` : `this output`));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="donemessagelarge">
|
||||
<p>
|
||||
Started tool <b>{{ props.toolName }}</b> and successfully added {{ nJobsText }} to the queue.
|
||||
</p>
|
||||
<p>It produces {{ nOutputsText }}:</p>
|
||||
<ul data-description="list of outputs">
|
||||
<li v-for="item of outputs" :key="item.hid">
|
||||
<b>{{ item.hid }}: {{ item.name }}</b>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
You can check the status of queued jobs and view the resulting data by refreshing the History panel. When
|
||||
the job has been run the status will change from 'running' to 'finished' if completed successfully or
|
||||
'error' if problems were encountered.
|
||||
</p>
|
||||
<p v-if="nJobs > 1">
|
||||
Here is a link to each job:
|
||||
<ExternalLink
|
||||
v-for="job in jobResponse.jobs"
|
||||
:key="job.id"
|
||||
:href="`/jobs/${job.id}/view`"
|
||||
data-description="job link">
|
||||
{{ job.id }}
|
||||
</ExternalLink>
|
||||
</p>
|
||||
<p v-else-if="jobResponse.jobs[0]" data-description="singular job link">
|
||||
Here is a link to the job:
|
||||
<RouterLink :to="`/jobs/${jobResponse.jobs[0].id}/view`">
|
||||
{{ jobResponse.jobs[0]?.id }}
|
||||
</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { faSignOutAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import { computed } from "vue";
|
||||
|
||||
import type { JobResponse } from "@/api/jobs";
|
||||
|
||||
import DetailBlock from "@/components/Common/DetailBlock.vue";
|
||||
import GenericItem from "@/components/History/Content/GenericItem.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
jobResponse: JobResponse;
|
||||
collapsed: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:collapsed", value: boolean): void;
|
||||
}>();
|
||||
|
||||
const outputs = computed(() => [
|
||||
...(props.jobResponse?.outputs || []),
|
||||
...(props.jobResponse?.output_collections || []),
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DetailBlock
|
||||
v-if="outputs.length"
|
||||
:collapsed="props.collapsed"
|
||||
:header-icon="faSignOutAlt"
|
||||
title="Tool Run Outputs"
|
||||
collapsible
|
||||
@update:collapsed="emit('update:collapsed', $event)">
|
||||
<div class="outputs-grid">
|
||||
<GenericItem
|
||||
v-for="output in outputs"
|
||||
:key="output.id"
|
||||
:item-id="output.id"
|
||||
:item-src="output.history_content_type === 'dataset' ? 'hda' : 'hdca'" />
|
||||
</div>
|
||||
</DetailBlock>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.outputs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.25rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Table Of Contents for the tool success page, allowing quick navigation to different sections.
|
||||
*/
|
||||
|
||||
import { computed } from "vue";
|
||||
|
||||
import type { JobRequest, JobResponse } from "@/api/jobs";
|
||||
|
||||
interface TocEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Set when this section can be force-expanded (i.e. it's one of the collapsible ones). */
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
jobResponse?: JobResponse;
|
||||
jobDef?: JobRequest;
|
||||
webhookId?: string | null;
|
||||
showRecommendation: boolean;
|
||||
/** Controls `JobInformation`'s collapse state, so its TOC entry can force it open. */
|
||||
jobInformationCollapsed: boolean;
|
||||
/** Controls `ToolSuccessOutputs`' collapse state, so its TOC entry can force it open. */
|
||||
toolSuccessOutputsCollapsed: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:jobInformationCollapsed", value: boolean): void;
|
||||
(e: "update:toolSuccessOutputsCollapsed", value: boolean): void;
|
||||
}>();
|
||||
|
||||
const entries = computed<TocEntry[]>(() => {
|
||||
const list: TocEntry[] = [];
|
||||
if (props.jobResponse?.produces_entry_points) {
|
||||
list.push({ id: "tool-entry-points", label: "Interactive Tools" });
|
||||
}
|
||||
list.push({
|
||||
id: "job-execution-details",
|
||||
label: "Job Execution Details",
|
||||
collapsed: props.jobInformationCollapsed,
|
||||
});
|
||||
if (props.jobResponse && (props.jobResponse.outputs?.length || props.jobResponse.output_collections?.length)) {
|
||||
list.push({ id: "tool-run-outputs", label: "Tool Run Outputs", collapsed: props.toolSuccessOutputsCollapsed });
|
||||
}
|
||||
if (props.jobDef && props.webhookId) {
|
||||
list.push({ id: "tool-success-webhook", label: "Before You Go" });
|
||||
}
|
||||
if (props.showRecommendation && props.jobDef?.tool_id) {
|
||||
list.push({ id: "tool-recommendation", label: "Tool Recommendation" });
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
function goTo(entry: TocEntry) {
|
||||
if (entry.collapsed) {
|
||||
if (entry.id === "job-execution-details") {
|
||||
emit("update:jobInformationCollapsed", false);
|
||||
} else if (entry.id === "tool-run-outputs") {
|
||||
emit("update:toolSuccessOutputsCollapsed", false);
|
||||
}
|
||||
}
|
||||
document.getElementById(entry.id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav v-if="entries.length > 1" class="toc-strip" aria-label="Jump to section">
|
||||
<button v-for="entry in entries" :key="entry.id" type="button" class="toc-link" @click="goTo(entry)">
|
||||
{{ entry.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.toc-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.toc-link {
|
||||
appearance: none;
|
||||
border: 1px solid var(--color-grey-200);
|
||||
background: var(--color-blue-100);
|
||||
color: var(--color-blue-600);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.75rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: var(--color-yellow-100);
|
||||
color: var(--color-grey-700);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--color-yellow-200);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -67,10 +67,17 @@ async function buildJobResponse(toolRequestDetail: ToolRequestDetailedModel) {
|
||||
rethrowSimple(error);
|
||||
}
|
||||
// TODO: The dataset response is not modeled yet in the FastAPI route for /api/datasets/{dataset_id}
|
||||
const dataset = data as { hid: number; name: string };
|
||||
const dataset = data as {
|
||||
hid: number;
|
||||
name: string;
|
||||
history_content_type: "dataset";
|
||||
id: string;
|
||||
};
|
||||
return {
|
||||
hid: dataset.hid,
|
||||
name: dataset.name,
|
||||
history_content_type: "dataset",
|
||||
id: dataset.id,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -85,7 +92,12 @@ async function buildJobResponse(toolRequestDetail: ToolRequestDetailedModel) {
|
||||
if (error) {
|
||||
rethrowSimple(error);
|
||||
}
|
||||
return { hid: data.hid as number, name: data.name as string };
|
||||
return {
|
||||
hid: data.hid as number,
|
||||
name: data.name as string,
|
||||
history_content_type: "dataset_collection",
|
||||
id: data.id as string,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -100,7 +112,7 @@ async function buildJobResponse(toolRequestDetail: ToolRequestDetailedModel) {
|
||||
return {
|
||||
// TODO: Once/If legacy job submission is typed, we won't need this cast to generic JobResponse
|
||||
jobs: jobs as JobResponse["jobs"],
|
||||
outputs,
|
||||
output_collections,
|
||||
outputs: outputs as JobResponse["outputs"],
|
||||
output_collections: output_collections as JobResponse["output_collections"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,4 +120,45 @@ describe("ToolEntryPoints/ToolEntryPoints.vue", () => {
|
||||
expect(link.exists()).toBe(true);
|
||||
expect(link.attributes("target")).toBe("_blank");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[undefined, null, null],
|
||||
[0, null, null],
|
||||
[
|
||||
1,
|
||||
"+1 job, paginate to view",
|
||||
"This run has 1 other job. Use the pagination above to view their Interactive Tools.",
|
||||
],
|
||||
[
|
||||
3,
|
||||
"+3 jobs, paginate to view",
|
||||
"This run has 3 other jobs. Use the pagination above to view their Interactive Tools.",
|
||||
],
|
||||
])("shows the other jobs hint for otherJobsCount=%s", async (otherJobsCount, expectedLabel, expectedTitle) => {
|
||||
testPinia = createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
initialState: {
|
||||
entryPointStore: {
|
||||
entryPoints: ACTIVE_ITS,
|
||||
},
|
||||
},
|
||||
});
|
||||
setActivePinia(testPinia);
|
||||
wrapper = mount(ToolEntryPoints, {
|
||||
props: {
|
||||
jobId: "b887d74393f85b6d",
|
||||
otherJobsCount,
|
||||
},
|
||||
global: localVue,
|
||||
pinia: testPinia,
|
||||
router,
|
||||
});
|
||||
const hint = wrapper.find(".other-jobs-hint");
|
||||
if (expectedLabel === null) {
|
||||
expect(hint.exists()).toBe(false);
|
||||
} else {
|
||||
expect(hint.text()).toBe(expectedLabel);
|
||||
expect(hint.attributes("title")).toBe(expectedTitle);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,18 +12,30 @@ import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed } from "vue";
|
||||
|
||||
import type { JobBaseModel, ShowFullJobResponse } from "@/api/jobs";
|
||||
import type { CardAction, CardBadge } from "@/components/Common/GCard.types";
|
||||
import { useJobDetails } from "@/composables/jobDetails";
|
||||
import { useEntryPointStore } from "@/stores/entryPointStore";
|
||||
import { stateIsTerminal } from "@/utils/utils";
|
||||
|
||||
import GButton from "@/components/BaseComponents/GButton.vue";
|
||||
import DetailBlock from "@/components/Common/DetailBlock.vue";
|
||||
import GCard from "@/components/Common/GCard.vue";
|
||||
import Heading from "@/components/Common/Heading.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
jobId: string;
|
||||
/** A job object. `undefined` fetches/polls it here instead; `null` means
|
||||
* the parent confirms there's deliberately no job yet (also skips fetching). */
|
||||
job?: JobBaseModel | ShowFullJobResponse | null;
|
||||
/** How many other jobs the same run submitted. When > 0, a header hint points to the
|
||||
* pagination so the Interactive Tools of a batch run's other jobs are not missed. */
|
||||
otherJobsCount?: number;
|
||||
}>();
|
||||
|
||||
const { entryPointsForJob } = storeToRefs(useEntryPointStore());
|
||||
const fetchJobId = computed(() => (props.job === undefined ? props.jobId : undefined));
|
||||
const { job: fetchedJob } = useJobDetails(fetchJobId, { full: false });
|
||||
const job = computed(() => (props.job !== undefined ? props.job : fetchedJob.value));
|
||||
|
||||
const badges = computed<CardBadge[]>(() => {
|
||||
const total = entryPointsForJob.value(props.jobId).length;
|
||||
@@ -33,6 +45,17 @@ const badges = computed<CardBadge[]>(() => {
|
||||
return [{ id: "entry-points-counter", label, variant: "outline-info", title: "" }];
|
||||
});
|
||||
|
||||
const otherJobsHint = computed(() => {
|
||||
if (!props.otherJobsCount) {
|
||||
return null;
|
||||
}
|
||||
const jobs = props.otherJobsCount > 1 ? "jobs" : "job";
|
||||
return {
|
||||
label: `+${props.otherJobsCount} ${jobs}, paginate to view`,
|
||||
title: `This run has ${props.otherJobsCount} other ${jobs}. Use the pagination above to view their Interactive Tools.`,
|
||||
};
|
||||
});
|
||||
|
||||
const primaryActions = computed(() => {
|
||||
const actions: CardAction[] = [];
|
||||
if (entryPointsForJob.value(props.jobId).length === 1 && entryPointsForJob.value(props.jobId)[0]?.active) {
|
||||
@@ -61,10 +84,18 @@ const secondaryActions: CardAction[] = [
|
||||
|
||||
const currentStatus = computed<{ title: string; icon: IconDefinition; class?: string }>(() => {
|
||||
if (entryPointsForJob.value(props.jobId).length === 0) {
|
||||
return {
|
||||
title: "No Interactive Tool sessions are currently available",
|
||||
icon: faMinus,
|
||||
};
|
||||
if (!job.value || !stateIsTerminal(job.value)) {
|
||||
return {
|
||||
title: "Waiting for Interactive Tool session(s) to become available",
|
||||
icon: faSpinner,
|
||||
class: "fa-spin",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
title: "No Interactive Tool sessions are currently available",
|
||||
icon: faMinus,
|
||||
};
|
||||
}
|
||||
} else if (entryPointsForJob.value(props.jobId).length === 1) {
|
||||
if (entryPointsForJob.value(props.jobId)[0]?.active) {
|
||||
return {
|
||||
@@ -86,58 +117,64 @@ const currentStatus = computed<{ title: string; icon: IconDefinition; class?: st
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Heading inline size="sm" bold separator>Interactive Tools</Heading>
|
||||
|
||||
<GCard
|
||||
:content-class="currentStatus.class === 'fa-spin' ? 'entry-points-card-loading' : undefined"
|
||||
:badges="badges"
|
||||
:primary-actions="primaryActions"
|
||||
:secondary-actions="secondaryActions"
|
||||
:title="currentStatus.title"
|
||||
:title-icon="currentStatus"
|
||||
title-size="text">
|
||||
<template v-slot:description>
|
||||
<div v-if="entryPointsForJob(props.jobId).length > 1" class="entry-points-grid">
|
||||
<GButton
|
||||
v-for="entryPoint of entryPointsForJob(props.jobId)"
|
||||
:key="entryPoint.id"
|
||||
data-description="entry point button"
|
||||
:disabled="!entryPoint.active"
|
||||
:disabled-title="`${entryPoint.name} is waiting to become active...`"
|
||||
:href="entryPoint.active ? entryPoint.target : undefined"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
color="blue"
|
||||
outline
|
||||
size="small"
|
||||
title="Open in a new tab">
|
||||
<div class="d-flex justify-content-between align-items-center flex-gapx-1 w-100">
|
||||
<div class="d-flex align-items-center flex-gapx-1">
|
||||
<FontAwesomeIcon
|
||||
fixed-width
|
||||
:class="{ 'status-dot': entryPoint.active }"
|
||||
:icon="entryPoint.active ? faCircle : faSpinner"
|
||||
:spin="!entryPoint.active" />
|
||||
{{ entryPoint.name }}
|
||||
<DetailBlock :header-icon="faLaptop" title="Interactive Tools">
|
||||
<template v-slot:header-indicators>
|
||||
<span v-if="otherJobsHint" class="other-jobs-hint" :title="otherJobsHint.title">
|
||||
<FontAwesomeIcon :icon="faLayerGroup" />
|
||||
{{ otherJobsHint.label }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-slot:custom-content>
|
||||
<GCard
|
||||
:content-class="currentStatus.class === 'fa-spin' ? 'entry-points-card-loading' : undefined"
|
||||
:badges="badges"
|
||||
:primary-actions="primaryActions"
|
||||
:secondary-actions="secondaryActions"
|
||||
:title="currentStatus.title"
|
||||
:title-icon="currentStatus"
|
||||
title-size="text">
|
||||
<template v-slot:description>
|
||||
<div v-if="entryPointsForJob(props.jobId).length > 1" class="entry-points-grid">
|
||||
<GButton
|
||||
v-for="entryPoint of entryPointsForJob(props.jobId)"
|
||||
:key="entryPoint.id"
|
||||
data-description="entry point button"
|
||||
:disabled="!entryPoint.active"
|
||||
:disabled-title="`${entryPoint.name} is waiting to become active...`"
|
||||
:href="entryPoint.active ? entryPoint.target : undefined"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
color="blue"
|
||||
outline
|
||||
size="small"
|
||||
title="Open in a new tab">
|
||||
<div class="d-flex justify-content-between align-items-center flex-gapx-1 w-100">
|
||||
<div class="d-flex align-items-center flex-gapx-1">
|
||||
<FontAwesomeIcon
|
||||
fixed-width
|
||||
:class="{ 'status-dot': entryPoint.active }"
|
||||
:icon="entryPoint.active ? faCircle : faSpinner"
|
||||
:spin="!entryPoint.active" />
|
||||
{{ entryPoint.name }}
|
||||
</div>
|
||||
<FontAwesomeIcon :icon="faExternalLinkAlt" />
|
||||
</div>
|
||||
<FontAwesomeIcon :icon="faExternalLinkAlt" />
|
||||
</div>
|
||||
</GButton>
|
||||
</div>
|
||||
</template>
|
||||
</GButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:update-time>
|
||||
<i
|
||||
v-if="
|
||||
entryPointsForJob(props.jobId).length > 1 &&
|
||||
entryPointsForJob(props.jobId).some((ep) => !ep.active)
|
||||
">
|
||||
Some sessions are not active yet
|
||||
</i>
|
||||
</template>
|
||||
</GCard>
|
||||
</div>
|
||||
<template v-slot:update-time>
|
||||
<i
|
||||
v-if="
|
||||
entryPointsForJob(props.jobId).length > 1 &&
|
||||
entryPointsForJob(props.jobId).some((ep) => !ep.active)
|
||||
">
|
||||
Some sessions are not active yet
|
||||
</i>
|
||||
</template>
|
||||
</GCard>
|
||||
</template>
|
||||
</DetailBlock>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -147,6 +184,16 @@ const currentStatus = computed<{ title: string; icon: IconDefinition; class?: st
|
||||
}
|
||||
}
|
||||
|
||||
.other-jobs-hint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
color: var(--color-blue-600);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entry-points-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { faForward } from "@fortawesome/free-solid-svg-icons";
|
||||
import * as d3 from "d3";
|
||||
import { nextTick, onMounted, ref } from "vue";
|
||||
|
||||
@@ -10,6 +11,7 @@ import { errorMessageAsString } from "@/utils/simple-error";
|
||||
import { getShortToolId } from "@/utils/tool";
|
||||
|
||||
import GAlert from "@/components/BaseComponents/GAlert.vue";
|
||||
import DetailBlock from "@/components/Common/DetailBlock.vue";
|
||||
|
||||
/** Vertical space to give each leaf row, so the tree stays readable regardless of the
|
||||
* container's measured height (which may be 0 before the SVG has any content). */
|
||||
@@ -238,24 +240,26 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<GAlert v-if="errorMessage" variant="warning" show>
|
||||
Tool recommendations could not be loaded: {{ errorMessage }}
|
||||
</GAlert>
|
||||
<div v-else aria-labelledby="tool-recommendation-heading">
|
||||
<div v-if="deprecated" class="warningmessagelarge">
|
||||
<h2 id="tool-recommendation-heading" class="h-sm">Tool deprecated</h2>
|
||||
You have used {{ getShortToolId(props.toolId) }} tool. {{ deprecatedMessage }}
|
||||
</div>
|
||||
<template v-else-if="showMessage">
|
||||
<h2 id="tool-recommendation-heading" class="h-sm">Tool recommendation</h2>
|
||||
<div>
|
||||
You have used {{ getShortToolId(props.toolId) }} tool. For further analysis, you could try using the
|
||||
following/recommended tools. The recommended tools are shown in the decreasing order of their scores
|
||||
predicted using machine learning analysis on workflows. Therefore, tools at the top may be more useful
|
||||
than the ones at the bottom. Please click on one of the following/recommended tools to open its
|
||||
definition.
|
||||
<DetailBlock :header-icon="faForward" title="Tool Recommendation">
|
||||
<GAlert v-if="errorMessage" variant="warning" show>
|
||||
Tool recommendations could not be loaded: {{ errorMessage }}
|
||||
</GAlert>
|
||||
<div v-else aria-labelledby="tool-recommendation-heading">
|
||||
<div v-if="deprecated" class="warningmessagelarge">
|
||||
<h2 id="tool-recommendation-heading" class="h-sm">Tool deprecated</h2>
|
||||
You have used {{ getShortToolId(props.toolId) }} tool. {{ deprecatedMessage }}
|
||||
</div>
|
||||
<div ref="toolRecommendation" class="ui-tool-recommendation"></div>
|
||||
</template>
|
||||
</div>
|
||||
<template v-else-if="showMessage">
|
||||
<h2 id="tool-recommendation-heading" class="h-sm">Try one of these recommended next steps</h2>
|
||||
<p>
|
||||
You have used <code>{{ getShortToolId(props.toolId) }}</code> tool. For further analysis, you could
|
||||
try using the following/recommended tools. The recommended tools are shown in the decreasing order
|
||||
of their scores predicted using machine learning analysis on workflows. Therefore, tools at the top
|
||||
may be more useful than the ones at the bottom. Please click on one of the following/recommended
|
||||
tools to open its definition.
|
||||
</p>
|
||||
<div ref="toolRecommendation" class="ui-tool-recommendation"></div>
|
||||
</template>
|
||||
</div>
|
||||
</DetailBlock>
|
||||
</template>
|
||||
|
||||
@@ -2,8 +2,10 @@ import { createTestingPinia } from "@pinia/testing";
|
||||
import { nth } from "@tests/vitest/helpers";
|
||||
import { mount, shallowMount } from "@vue/test-utils";
|
||||
import flushPromises from "flush-promises";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { computed, type Ref, ref } from "vue";
|
||||
|
||||
import { useServerMock } from "@/api/client/__mocks__";
|
||||
import type { JobBaseModel } from "@/api/jobs";
|
||||
import { statePlaceholders } from "@/composables/useInvocationGraph";
|
||||
|
||||
@@ -17,6 +19,28 @@ const TEST_JOBS_JSON = TEST_JOBS_JSON_RAW as JobBaseModel[];
|
||||
|
||||
const TEST_INVOCATION_ID = "test-invocation-id";
|
||||
|
||||
const mockedJobsById = ref<Record<string, JobBaseModel>>({});
|
||||
|
||||
function mockJobs(jobs: JobBaseModel[]) {
|
||||
mockedJobsById.value = Object.fromEntries(jobs.map((job) => [job.id, job]));
|
||||
}
|
||||
|
||||
// Since `JobState` fetches its job via `useJobDetails`, we need to mock that composable to return
|
||||
// jobs reactively, as the table changes values as the states change.
|
||||
vi.mock("@/composables/jobDetails", () => ({
|
||||
useJobDetails: (jobId: Ref<string | undefined>) => ({
|
||||
job: computed(() => (jobId.value ? (mockedJobsById.value[jobId.value] ?? null) : null)),
|
||||
error: computed(() => null),
|
||||
loading: computed(() => false),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { server, http } = useServerMock();
|
||||
|
||||
beforeEach(() => {
|
||||
server.use(http.get("/api/invocations/{invocation_id}/metrics", ({ response }) => response(200).json([])));
|
||||
});
|
||||
|
||||
const SELECTORS = {
|
||||
JOB_STATE_BUTTON_NAV: "nav",
|
||||
JOB_STATE_BUTTON: ".g-button",
|
||||
@@ -29,6 +53,8 @@ const SELECTORS = {
|
||||
|
||||
describe("Job Step", () => {
|
||||
it("shows jobs grouped by state in tables when multiple jobs", async () => {
|
||||
mockJobs(TEST_JOBS_JSON as JobBaseModel[]);
|
||||
|
||||
const wrapper = mount(JobStep, {
|
||||
props: {
|
||||
jobs: TEST_JOBS_JSON,
|
||||
@@ -78,6 +104,8 @@ describe("Job Step", () => {
|
||||
});
|
||||
|
||||
it("reacts to job states changing when multiple jobs", async () => {
|
||||
mockJobs(TEST_JOBS_JSON as JobBaseModel[]);
|
||||
|
||||
const wrapper = mount(JobStep, {
|
||||
props: {
|
||||
jobs: TEST_JOBS_JSON,
|
||||
@@ -108,6 +136,7 @@ describe("Job Step", () => {
|
||||
|
||||
// we trigger the state change by updating the first job's state from 'new' to 'running'
|
||||
const updatedJob: JobBaseModel = { ...nth(TEST_JOBS_JSON, 0), state: "running" };
|
||||
mockJobs([updatedJob, ...(TEST_JOBS_JSON.slice(1) as JobBaseModel[])]);
|
||||
await wrapper.setProps({ jobs: [updatedJob, ...TEST_JOBS_JSON.slice(1)] });
|
||||
await flushPromises();
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import flushPromises from "flush-promises";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { HttpResponse, useServerMock } from "@/api/client/__mocks__";
|
||||
import type { JobDisplayParametersSummary, ShowFullJobResponse } from "@/api/jobs";
|
||||
import type { JobBaseModel, JobDisplayParametersSummary, ShowFullJobResponse } from "@/api/jobs";
|
||||
import paramResponse from "@/components/JobParameters/parameters-response.json";
|
||||
|
||||
import { TEST_JOBS_BY_STATES } from "./test/jobStepUtils";
|
||||
@@ -120,3 +120,28 @@ describe("JobStepJobs", () => {
|
||||
expect(wrapper.find("#galaxy-tool-id").text()).toBe(TEST_JOBS_BY_STATES["ok"]?.[0]?.tool_id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JobStepJobs state updates", () => {
|
||||
it("uses the parent jobs without fetching each visible row", async () => {
|
||||
let jobRequests = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response }) => {
|
||||
jobRequests++;
|
||||
return response(200).json(TEST_JOBS_JSON[0] as ShowFullJobResponse);
|
||||
}),
|
||||
);
|
||||
const job = { ...TEST_JOBS_JSON[0]!, state: "new" } as JobBaseModel;
|
||||
const wrapper = mount(JobStepJobs, {
|
||||
props: { jobs: [job], invocationId: "test-invocation-id", currentPage: 1, sortDesc: true, perPage: 10 },
|
||||
global: localVue,
|
||||
pinia: createTestingPinia({ createSpy: vi.fn, stubActions: false }),
|
||||
});
|
||||
await flushPromises();
|
||||
expect(wrapper.find(".job-state-badge").text()).toContain("new");
|
||||
await wrapper.setProps({ jobs: [{ ...job, state: "running" }] });
|
||||
await flushPromises();
|
||||
expect(wrapper.find(".job-state-badge").text()).toContain("running");
|
||||
expect(jobRequests).toBe(0);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,7 +156,7 @@ watch(
|
||||
</template>
|
||||
|
||||
<template v-slot:cell(state)="data">
|
||||
<JobState :job="data.item" />
|
||||
<JobState :job-id="data.item.id" :job="data.item" />
|
||||
</template>
|
||||
|
||||
<template v-slot:cell(duration)="data">
|
||||
|
||||
@@ -1,52 +1,6 @@
|
||||
import axios from "axios";
|
||||
|
||||
import { SingleQueryProvider } from "@/components/providers/SingleQueryProvider";
|
||||
import { getAppRoot } from "@/onload/loadConfig";
|
||||
import { rethrowSimple } from "@/utils/simple-error";
|
||||
|
||||
import { cleanPaginationParameters, stateIsTerminal } from "./utils";
|
||||
|
||||
async function jobDetails({ jobId }) {
|
||||
const url = `${getAppRoot()}api/jobs/${jobId}?full=True`;
|
||||
try {
|
||||
const { data } = await axios.get(url);
|
||||
return data;
|
||||
} catch (e) {
|
||||
rethrowSimple(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function jobConsoleOutput({
|
||||
jobId,
|
||||
stdout_position = 0,
|
||||
stdout_length = 0,
|
||||
stderr_position = 0,
|
||||
stderr_length = 0,
|
||||
}) {
|
||||
const url =
|
||||
`${getAppRoot()}api/jobs/${jobId}/console_output?stdout_position=${stdout_position}&stdout_length=${stdout_length}` +
|
||||
`&stderr_position=${stderr_position}&stderr_length=${stderr_length}`;
|
||||
try {
|
||||
const { status, data } = await axios.get(url, {
|
||||
validateStatus: function (status) {
|
||||
return status == 200 || status == 403;
|
||||
},
|
||||
});
|
||||
if (status == 403) {
|
||||
if (data.err_code == 403004) {
|
||||
console.log("This job destination does not support console output");
|
||||
return { state: "ok" };
|
||||
}
|
||||
throw Error("Problem fetching state");
|
||||
}
|
||||
return data;
|
||||
} catch (e) {
|
||||
rethrowSimple(e);
|
||||
}
|
||||
}
|
||||
|
||||
export const JobDetailsProvider = SingleQueryProvider(jobDetails, stateIsTerminal);
|
||||
export const JobConsoleOutputProvider = SingleQueryProvider(jobConsoleOutput, stateIsTerminal);
|
||||
import { cleanPaginationParameters } from "./utils";
|
||||
|
||||
export function jobsProvider(ctx, callback, extraParams = {}) {
|
||||
const { root, ...requestParams } = ctx;
|
||||
|
||||
@@ -2,12 +2,14 @@ import { computed, type Ref, ref, watch } from "vue";
|
||||
|
||||
import { ERROR_STATES, TERMINAL_STATES } from "@/api/jobs";
|
||||
import { type FetchDataPayload, fetchDatasetsToJobId, fetchJobErrorMessage } from "@/api/tools";
|
||||
import { useJobDetails } from "@/composables/jobDetails";
|
||||
import { useResourceWatcher } from "@/composables/resourceWatcher";
|
||||
import { useJobStore } from "@/stores/jobStore";
|
||||
import { errorMessageAsString } from "@/utils/simple-error";
|
||||
|
||||
export function useJobWatcher(jobId: Ref<string | undefined>) {
|
||||
const { fetchJob, getJob } = useJobStore();
|
||||
const { fetchJob } = useJobStore();
|
||||
const { job } = useJobDetails(jobId, { poll: false });
|
||||
const jobRequestError = ref<string | undefined>(undefined);
|
||||
|
||||
async function jobResourceHandler() {
|
||||
@@ -22,17 +24,6 @@ export function useJobWatcher(jobId: Ref<string | undefined>) {
|
||||
}
|
||||
}
|
||||
|
||||
const job = computed(() => {
|
||||
if (jobId.value) {
|
||||
const jobIdVal = jobId.value;
|
||||
const job = getJob(jobIdVal);
|
||||
if (job) {
|
||||
return job;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const { startWatchingResourceIfNeeded, stopWatchingResourceIfNeeded } = useResourceWatcher(jobResourceHandler);
|
||||
|
||||
watch(
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { createTestingPinia } from "@pinia/testing";
|
||||
import { advanceTimersAndFlush } from "@tests/vitest/helpers";
|
||||
import { enableAutoUnmount, mount } from "@vue/test-utils";
|
||||
import flushPromises from "flush-promises";
|
||||
import { setActivePinia } from "pinia";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { defineComponent, type Ref, ref } from "vue";
|
||||
|
||||
import { useServerMock } from "@/api/client/__mocks__";
|
||||
import type { JobConsoleOutput } from "@/api/jobs";
|
||||
|
||||
import { useJobConsoleOutput } from "./jobDetails";
|
||||
|
||||
const { server, http } = useServerMock();
|
||||
|
||||
vi.useFakeTimers();
|
||||
enableAutoUnmount(afterEach);
|
||||
|
||||
function buildConsoleOutput(overrides: Partial<JobConsoleOutput> = {}): JobConsoleOutput {
|
||||
return { state: "running", stdout: "", stderr: "", ...overrides };
|
||||
}
|
||||
|
||||
function mountJobConsoleOutput(jobId: Ref<string | undefined>) {
|
||||
const mounted = {
|
||||
stdout: ref(""),
|
||||
stderr: ref(""),
|
||||
error: ref<unknown>(null),
|
||||
};
|
||||
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
Object.assign(mounted, useJobConsoleOutput(jobId));
|
||||
return () => null;
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mount(TestComponent);
|
||||
return { ...mounted, wrapper };
|
||||
}
|
||||
|
||||
describe("useJobConsoleOutput", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ createSpy: vi.fn, stubActions: false }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
it("accumulates stdout/stderr across polls", async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}/console_output", ({ response }) => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return response(200).json(buildConsoleOutput({ stdout: "line1\n", stderr: "" }));
|
||||
}
|
||||
return response(200).json(buildConsoleOutput({ stdout: "line2\n", stderr: "warn\n" }));
|
||||
}),
|
||||
);
|
||||
|
||||
const jobId = ref<string | undefined>("job1");
|
||||
const { stdout, stderr } = mountJobConsoleOutput(jobId);
|
||||
await flushPromises();
|
||||
|
||||
expect(stdout.value).toBe("line1\n");
|
||||
expect(stderr.value).toBe("");
|
||||
|
||||
await advanceTimersAndFlush(3000);
|
||||
expect(stdout.value).toBe("line1\nline2\n");
|
||||
expect(stderr.value).toBe("warn\n");
|
||||
});
|
||||
|
||||
it("stops polling once the job's console state is terminal", async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}/console_output", ({ response }) => {
|
||||
callCount++;
|
||||
return response(200).json(buildConsoleOutput({ state: "ok", stdout: "done\n" }));
|
||||
}),
|
||||
);
|
||||
|
||||
const jobId = ref<string | undefined>("job1");
|
||||
const { stdout } = mountJobConsoleOutput(jobId);
|
||||
await flushPromises();
|
||||
|
||||
expect(stdout.value).toBe("done\n");
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
await advanceTimersAndFlush(5000);
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("does not keep a timer running when the tab regains focus after the output is complete", async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}/console_output", ({ response }) => {
|
||||
callCount++;
|
||||
return response(200).json(buildConsoleOutput({ state: "ok", stdout: "done\n" }));
|
||||
}),
|
||||
);
|
||||
|
||||
mountJobConsoleOutput(ref<string | undefined>("job1"));
|
||||
await flushPromises();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
// `useResourceWatcher` restarts a stopped watcher (without its job id) on refocus
|
||||
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
await advanceTimersAndFlush(10000);
|
||||
|
||||
expect(callCount).toBe(1);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps polling after a failed request instead of treating it as terminal", async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}/console_output", ({ response }) => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return response("5XX").json({ err_msg: "Server error", err_code: 500 }, { status: 500 });
|
||||
}
|
||||
return response(200).json(buildConsoleOutput({ stdout: "still running\n" }));
|
||||
}),
|
||||
);
|
||||
|
||||
const jobId = ref<string | undefined>("job1");
|
||||
const { stdout, error } = mountJobConsoleOutput(jobId);
|
||||
await flushPromises();
|
||||
|
||||
// The first request failed, and nothing is known about the job's actual state yet
|
||||
// (state defaults to undefined). That must not be read as "terminal" and stop polling.
|
||||
expect(error.value).toBeTruthy();
|
||||
expect(stdout.value).toBe("");
|
||||
|
||||
await advanceTimersAndFlush(3000);
|
||||
expect(callCount).toBe(2);
|
||||
expect(stdout.value).toBe("still running\n");
|
||||
});
|
||||
|
||||
it("resets accumulated output when jobId changes", async () => {
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}/console_output", ({ params, response }) => {
|
||||
const id = params.job_id;
|
||||
return response(200).json(buildConsoleOutput({ stdout: `output for ${id}\n` }));
|
||||
}),
|
||||
);
|
||||
|
||||
const jobId = ref<string | undefined>("job1");
|
||||
const { stdout } = mountJobConsoleOutput(jobId);
|
||||
await flushPromises();
|
||||
expect(stdout.value).toBe("output for job1\n");
|
||||
|
||||
jobId.value = "job2";
|
||||
await flushPromises();
|
||||
expect(stdout.value).toBe("output for job2\n");
|
||||
});
|
||||
|
||||
it("does not fetch when jobId is undefined", async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}/console_output", ({ response }) => {
|
||||
callCount++;
|
||||
return response(200).json(buildConsoleOutput());
|
||||
}),
|
||||
);
|
||||
|
||||
const jobId = ref<string | undefined>(undefined);
|
||||
const { stdout } = mountJobConsoleOutput(jobId);
|
||||
await flushPromises();
|
||||
|
||||
expect(stdout.value).toBe("");
|
||||
expect(callCount).toBe(0);
|
||||
});
|
||||
|
||||
it("stops polling once the consuming component unmounts", async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}/console_output", ({ response }) => {
|
||||
callCount++;
|
||||
return response(200).json(buildConsoleOutput({ stdout: "line\n" }));
|
||||
}),
|
||||
);
|
||||
|
||||
const jobId = ref<string | undefined>("job1");
|
||||
const { wrapper } = mountJobConsoleOutput(jobId);
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
wrapper.unmount();
|
||||
|
||||
await advanceTimersAndFlush(5000);
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("ignores a stale response from a previous visit to the same job id", async () => {
|
||||
// job1's first fetch is held open so it resolves only after we've switched away and back
|
||||
// to job1, by which point it's a leftover from an earlier visit, not the current one.
|
||||
let resolveFirstFetchForJob1: (output: JobConsoleOutput) => void;
|
||||
let sawFirstFetchForJob1 = false;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}/console_output", ({ response, params }) => {
|
||||
if (params.job_id === "job1" && !sawFirstFetchForJob1) {
|
||||
sawFirstFetchForJob1 = true;
|
||||
return new Promise((resolve) => {
|
||||
resolveFirstFetchForJob1 = (output) => resolve(response(200).json(output));
|
||||
});
|
||||
}
|
||||
if (params.job_id === "job1") {
|
||||
return response(200).json(buildConsoleOutput({ stdout: "second visit to job1\n" }));
|
||||
}
|
||||
return response(200).json(buildConsoleOutput({ stdout: "job2 output\n" }));
|
||||
}),
|
||||
);
|
||||
|
||||
const jobId = ref<string | undefined>("job1");
|
||||
const { stdout, wrapper } = mountJobConsoleOutput(jobId);
|
||||
await flushPromises();
|
||||
|
||||
// Switch away to job2 (its own fetch resolves normally) and then back to job1, all while
|
||||
// job1's first fetch above is still pending.
|
||||
jobId.value = "job2";
|
||||
await flushPromises();
|
||||
|
||||
jobId.value = "job1";
|
||||
await flushPromises();
|
||||
|
||||
// The stale response from job1's first visit arrives now. It must not be appended.
|
||||
resolveFirstFetchForJob1!(buildConsoleOutput({ stdout: "stale, from the first visit to job1\n" }));
|
||||
await flushPromises();
|
||||
|
||||
expect(stdout.value).toBe("second visit to job1\n");
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsupported live console output", () => {
|
||||
afterEach(() => vi.clearAllTimers());
|
||||
|
||||
it("stops on the configuration refusal and can fetch a different job", async () => {
|
||||
let unsupportedCalls = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}/console_output", ({ response, params }) => {
|
||||
if (params.job_id === "unsupported") {
|
||||
unsupportedCalls++;
|
||||
return response("4XX").json({ err_code: 403004, err_msg: "Not enabled" }, { status: 403 });
|
||||
}
|
||||
return response(200).json(buildConsoleOutput({ stdout: "live output" }));
|
||||
}),
|
||||
);
|
||||
const jobId = ref<string | undefined>("unsupported");
|
||||
const { stdout, error } = mountJobConsoleOutput(jobId);
|
||||
await flushPromises();
|
||||
await advanceTimersAndFlush(3000);
|
||||
await advanceTimersAndFlush(3000);
|
||||
expect(unsupportedCalls).toBe(1);
|
||||
expect(error.value).toBeNull();
|
||||
jobId.value = "supported";
|
||||
await flushPromises();
|
||||
expect(stdout.value).toBe("live output");
|
||||
await advanceTimersAndFlush(3000);
|
||||
expect(stdout.value).toBe("live outputlive output");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import { createTestingPinia } from "@pinia/testing";
|
||||
import { advanceTimersAndFlush } from "@tests/vitest/helpers";
|
||||
import { enableAutoUnmount, mount } from "@vue/test-utils";
|
||||
import flushPromises from "flush-promises";
|
||||
import { setActivePinia } from "pinia";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { defineComponent, h, type Ref, ref } from "vue";
|
||||
|
||||
import { useServerMock } from "@/api/client/__mocks__";
|
||||
import type { JobState, ShowFullJobResponse } from "@/api/jobs";
|
||||
import { MAX_CACHED_JOBS, useJobStore } from "@/stores/jobStore";
|
||||
|
||||
import { useJobDetails } from "./jobDetails";
|
||||
|
||||
const { server, http } = useServerMock();
|
||||
|
||||
vi.useFakeTimers();
|
||||
enableAutoUnmount(afterEach);
|
||||
|
||||
function buildJob(id: string, state: JobState, overrides: Partial<ShowFullJobResponse> = {}): ShowFullJobResponse {
|
||||
return {
|
||||
id,
|
||||
state,
|
||||
model_class: "Job",
|
||||
create_time: "2024-01-01T00:00:00",
|
||||
update_time: "2024-01-01T00:00:00",
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
output_collections: {},
|
||||
params: {},
|
||||
tool_id: "cat1",
|
||||
...overrides,
|
||||
} as ShowFullJobResponse;
|
||||
}
|
||||
|
||||
function mountJobDetails(jobId: Ref<string | undefined>, options?: { full?: boolean; poll?: boolean }) {
|
||||
const mounted = {
|
||||
job: ref<ShowFullJobResponse | null>(null),
|
||||
error: ref<unknown>(null),
|
||||
loading: ref(false),
|
||||
};
|
||||
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
Object.assign(mounted, useJobDetails(jobId, options));
|
||||
return () => null;
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mount(TestComponent);
|
||||
return { ...mounted, wrapper };
|
||||
}
|
||||
|
||||
describe("useJobDetails", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ createSpy: vi.fn, stubActions: false }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
it("fetches the job on mount and exposes it reactively", async () => {
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response }) => {
|
||||
return response(200).json(buildJob("job1", "running"));
|
||||
}),
|
||||
);
|
||||
|
||||
const jobId = ref<string | undefined>("job1");
|
||||
const { job } = mountJobDetails(jobId);
|
||||
|
||||
expect(job.value).toBeNull();
|
||||
await flushPromises();
|
||||
|
||||
expect(job.value?.id).toBe("job1");
|
||||
expect(job.value?.state).toBe("running");
|
||||
});
|
||||
|
||||
it("keeps polling while the job is in a non-terminal state", async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response }) => {
|
||||
callCount++;
|
||||
return response(200).json(buildJob("job1", "running"));
|
||||
}),
|
||||
);
|
||||
|
||||
const jobId = ref<string | undefined>("job1");
|
||||
mountJobDetails(jobId);
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
await advanceTimersAndFlush(1000);
|
||||
expect(callCount).toBe(2);
|
||||
|
||||
await advanceTimersAndFlush(1000);
|
||||
expect(callCount).toBe(3);
|
||||
});
|
||||
|
||||
it("stops polling once the job reaches a terminal state", async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response }) => {
|
||||
callCount++;
|
||||
// Terminal on the very first response.
|
||||
return response(200).json(buildJob("job1", "ok"));
|
||||
}),
|
||||
);
|
||||
|
||||
const jobId = ref<string | undefined>("job1");
|
||||
const { job } = mountJobDetails(jobId);
|
||||
await flushPromises();
|
||||
|
||||
expect(job.value?.state).toBe("ok");
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
await advanceTimersAndFlush(5000);
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("does not fetch or poll when jobId is undefined", async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response }) => {
|
||||
callCount++;
|
||||
return response(200).json(buildJob("job1", "running"));
|
||||
}),
|
||||
);
|
||||
|
||||
const jobId = ref<string | undefined>(undefined);
|
||||
const { job } = mountJobDetails(jobId);
|
||||
await flushPromises();
|
||||
expect(job.value).toBeNull();
|
||||
|
||||
expect(callCount).toBe(0);
|
||||
});
|
||||
|
||||
it("surfaces a load error", async () => {
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response }) => {
|
||||
return response("4XX").json({ err_msg: "not found", err_code: 404 }, { status: 404 });
|
||||
}),
|
||||
);
|
||||
|
||||
const jobId = ref<string | undefined>("job1");
|
||||
const { job, error } = mountJobDetails(jobId);
|
||||
await flushPromises();
|
||||
|
||||
expect(job.value).toBeNull();
|
||||
expect(error.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stops polling once the consuming component unmounts", async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response }) => {
|
||||
callCount++;
|
||||
return response(200).json(buildJob("job1", "running"));
|
||||
}),
|
||||
);
|
||||
|
||||
const jobId = ref<string | undefined>("job1");
|
||||
const { wrapper } = mountJobDetails(jobId);
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
await advanceTimersAndFlush(1000);
|
||||
expect(callCount).toBe(2);
|
||||
|
||||
wrapper.unmount();
|
||||
|
||||
// With the poller stopped, further time passing must not produce any more requests,
|
||||
// otherwise it would keep hitting the server for the rest of the session even though
|
||||
// nothing is displaying this job anymore.
|
||||
await advanceTimersAndFlush(5000);
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
|
||||
it("stops the previous job's poll when jobId changes to a different job", async () => {
|
||||
let job1Calls = 0;
|
||||
let job2Calls = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response, params }) => {
|
||||
if (params.job_id === "job1") {
|
||||
job1Calls++;
|
||||
return response(200).json(buildJob("job1", "running"));
|
||||
}
|
||||
job2Calls++;
|
||||
return response(200).json(buildJob("job2", "running"));
|
||||
}),
|
||||
);
|
||||
|
||||
const jobId = ref<string | undefined>("job1");
|
||||
mountJobDetails(jobId);
|
||||
await flushPromises();
|
||||
expect(job1Calls).toBe(1);
|
||||
|
||||
jobId.value = "job2";
|
||||
await flushPromises();
|
||||
expect(job2Calls).toBe(1);
|
||||
|
||||
// job1's poll must have been released when we switched away from it, not left running
|
||||
// alongside job2's.
|
||||
await advanceTimersAndFlush(1000);
|
||||
expect(job1Calls).toBe(1);
|
||||
expect(job2Calls).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps polling a shared job alive until every consumer has unmounted", async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response }) => {
|
||||
callCount++;
|
||||
return response(200).json(buildJob("job1", "running"));
|
||||
}),
|
||||
);
|
||||
|
||||
const jobId = ref<string | undefined>("job1");
|
||||
const first = mountJobDetails(jobId);
|
||||
const second = mountJobDetails(jobId);
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1); // jobStore dedupes the actual request across both consumers
|
||||
|
||||
first.wrapper.unmount();
|
||||
|
||||
// The second consumer is still mounted and interested, so the poll must keep going.
|
||||
await advanceTimersAndFlush(1000);
|
||||
expect(callCount).toBe(2);
|
||||
|
||||
second.wrapper.unmount();
|
||||
|
||||
// Now that both consumers are gone, the poll must actually stop.
|
||||
await advanceTimersAndFlush(5000);
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("job cache consumers", () => {
|
||||
beforeEach(() => setActivePinia(createTestingPinia({ createSpy: vi.fn, stubActions: false })));
|
||||
afterEach(() => vi.clearAllTimers());
|
||||
|
||||
it("does not request full details when a base-only consumer renders before the response", async () => {
|
||||
const requests: (string | null)[] = [];
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response, request }) => {
|
||||
requests.push(new URL(request.url).searchParams.get("full"));
|
||||
return response(200).json(buildJob("base", "running"));
|
||||
}),
|
||||
);
|
||||
const { job } = mountJobDetails(ref("base"), { full: false });
|
||||
expect(job.value).toBeNull();
|
||||
await flushPromises();
|
||||
expect(job.value?.state).toBe("running");
|
||||
expect(requests).toEqual(["false"]);
|
||||
await advanceTimersAndFlush(1000);
|
||||
expect(requests).toEqual(["false", "false"]);
|
||||
});
|
||||
|
||||
it("keeps a one-shot running job visible after the terminal cache fills, without refetching", async () => {
|
||||
const calls: Record<string, number> = {};
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response, params }) => {
|
||||
const id = params.job_id as string;
|
||||
calls[id] = (calls[id] ?? 0) + 1;
|
||||
return response(200).json(buildJob(id, id === "live" ? "running" : "ok"));
|
||||
}),
|
||||
);
|
||||
const store = useJobStore();
|
||||
for (let i = 0; i < MAX_CACHED_JOBS; i++) {
|
||||
store.fetchJob({ id: `done-${i}` });
|
||||
await flushPromises();
|
||||
}
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
const { job } = useJobDetails(ref("live"), { poll: false });
|
||||
return () => h("div", job.value?.state ?? "loading");
|
||||
},
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toBe("running");
|
||||
for (let i = 0; i < MAX_CACHED_JOBS; i++) {
|
||||
store.fetchJob({ id: `next-${i}` });
|
||||
await flushPromises();
|
||||
}
|
||||
await advanceTimersAndFlush(5000);
|
||||
expect(wrapper.text()).toBe("running");
|
||||
expect(calls.live).toBe(1);
|
||||
wrapper.unmount();
|
||||
store.fetchJob({ id: "another" });
|
||||
await flushPromises();
|
||||
expect(store.getCachedJob("live")).toBeNull();
|
||||
});
|
||||
|
||||
it("retains more than the cache target while displayed, then releases entries on job change and unmount", async () => {
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response, params }) =>
|
||||
response(200).json(buildJob(params.job_id as string, "ok")),
|
||||
),
|
||||
);
|
||||
const store = useJobStore();
|
||||
const id = ref<string | undefined>("shared");
|
||||
const first = mountJobDetails(id, { poll: false });
|
||||
const second = mountJobDetails(ref("shared"), { poll: false });
|
||||
await flushPromises();
|
||||
const others = [];
|
||||
for (let i = 0; i < MAX_CACHED_JOBS; i++) {
|
||||
others.push(mountJobDetails(ref(`other-${i}`), { poll: false }));
|
||||
await flushPromises();
|
||||
}
|
||||
expect(first.job.value?.id).toBe("shared");
|
||||
expect(others.every(({ job }) => job.value !== null)).toBe(true);
|
||||
|
||||
id.value = "replacement";
|
||||
await flushPromises();
|
||||
expect(second.job.value?.id).toBe("shared");
|
||||
second.wrapper.unmount();
|
||||
expect(store.getCachedJob("shared")).toBeNull();
|
||||
expect(first.job.value?.id).toBe("replacement");
|
||||
first.wrapper.unmount();
|
||||
expect(store.getCachedJob("replacement")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { computed, onUnmounted, type Ref, ref, watch } from "vue";
|
||||
|
||||
import { GalaxyApi } from "@/api";
|
||||
import type { JobConsoleOutput } from "@/api/jobs";
|
||||
import { useResourceWatcher } from "@/composables/resourceWatcher";
|
||||
import { useJobStore } from "@/stores/jobStore";
|
||||
import { rethrowSimple } from "@/utils/simple-error";
|
||||
import { stateIsTerminal } from "@/utils/utils";
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_MS = 3000;
|
||||
|
||||
/** Reads a retained job and polls it until completion. Set `poll: false` for a single fetch. */
|
||||
export function useJobDetails(jobId: Ref<string | undefined>, options: { full?: boolean; poll?: boolean } = {}) {
|
||||
const { full = true, poll = true } = options;
|
||||
const jobStore = useJobStore();
|
||||
|
||||
const job = computed(() => (jobId.value ? jobStore.getCachedJob(jobId.value) : null));
|
||||
const error = computed(() => (jobId.value ? jobStore.getJobLoadError(jobId.value) : null));
|
||||
const loading = computed(() => (jobId.value ? jobStore.isLoadingJob(jobId.value) : false));
|
||||
|
||||
watch(
|
||||
jobId,
|
||||
(id, _oldId, onCleanup) => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const releaseJob = jobStore.retainJob(id);
|
||||
const subscription = poll ? jobStore.pollJobUntilTerminal({ id, full }) : undefined;
|
||||
if (!poll) {
|
||||
jobStore.fetchJob({ id, full });
|
||||
}
|
||||
onCleanup(() => {
|
||||
subscription?.stopWatchingJob();
|
||||
releaseJob?.();
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
return { job, error, loading };
|
||||
}
|
||||
|
||||
/** Fetches and auto-polls a job's console output (stdout/stderr)*/
|
||||
export function useJobConsoleOutput(
|
||||
jobId: Ref<string | undefined>,
|
||||
options: { pollInterval?: number; chunkLength?: number } = {},
|
||||
) {
|
||||
const { pollInterval = DEFAULT_POLL_INTERVAL_MS, chunkLength = 50000 } = options;
|
||||
|
||||
const stdout = ref("");
|
||||
const stderr = ref("");
|
||||
const state = ref<string | null | undefined>(undefined);
|
||||
const error = ref<unknown>(null);
|
||||
|
||||
let currentJobId: string | undefined;
|
||||
/**
|
||||
* Incremented on every `restart()` call. A response belongs to the current restart only if
|
||||
* this still matches the value captured when its fetch began; comparing `id` alone isn't
|
||||
* enough, since switching away from a job and back to it reuses the same id for a new restart.
|
||||
*/
|
||||
let restartCount = 0;
|
||||
/** Set once the current job's output is complete (terminal, or the destination can't report it). */
|
||||
let finished = false;
|
||||
|
||||
async function fetchConsoleOutput(id: string) {
|
||||
const {
|
||||
data,
|
||||
error: fetchError,
|
||||
response,
|
||||
} = await GalaxyApi().GET("/api/jobs/{job_id}/console_output", {
|
||||
params: {
|
||||
path: { job_id: id },
|
||||
query: {
|
||||
stdout_position: stdout.value.length,
|
||||
stdout_length: chunkLength,
|
||||
stderr_position: stderr.value.length,
|
||||
stderr_length: chunkLength,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (fetchError) {
|
||||
// Destinations without live output reporting cannot satisfy subsequent polls either.
|
||||
if (response.status === 403 && fetchError.err_code === 403004) {
|
||||
return null;
|
||||
}
|
||||
rethrowSimple(fetchError);
|
||||
}
|
||||
return data as JobConsoleOutput;
|
||||
}
|
||||
|
||||
const watcher = useResourceWatcher<string>(
|
||||
async (id?: unknown) => {
|
||||
const fetchRestartCount = restartCount;
|
||||
if (typeof id !== "string" || id !== currentJobId || finished) {
|
||||
// `useResourceWatcher` restarts a stopped watcher when the tab becomes visible again,
|
||||
// without the job id. Nothing is left to fetch, so stop instead of polling a no-op.
|
||||
watcher.stopWatchingResource();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await fetchConsoleOutput(id);
|
||||
if (fetchRestartCount !== restartCount) {
|
||||
return;
|
||||
}
|
||||
if (result === null) {
|
||||
error.value = null;
|
||||
finished = true;
|
||||
watcher.stopWatchingResource();
|
||||
return;
|
||||
}
|
||||
if (result.stdout != null) {
|
||||
stdout.value += result.stdout;
|
||||
}
|
||||
if (result.stderr != null) {
|
||||
stderr.value += result.stderr;
|
||||
}
|
||||
state.value = result.state;
|
||||
error.value = null;
|
||||
if (stateIsTerminal({ state: state.value })) {
|
||||
finished = true;
|
||||
watcher.stopWatchingResource();
|
||||
}
|
||||
} catch (e) {
|
||||
if (fetchRestartCount !== restartCount) {
|
||||
return;
|
||||
}
|
||||
error.value = e;
|
||||
}
|
||||
},
|
||||
{ shortPollingInterval: pollInterval, longPollingInterval: pollInterval },
|
||||
);
|
||||
|
||||
function restart(id: string | undefined) {
|
||||
watcher.stopWatchingResource();
|
||||
restartCount++;
|
||||
currentJobId = id;
|
||||
finished = false;
|
||||
stdout.value = "";
|
||||
stderr.value = "";
|
||||
state.value = undefined;
|
||||
error.value = null;
|
||||
if (id) {
|
||||
watcher.startWatchingResource(id);
|
||||
}
|
||||
}
|
||||
|
||||
watch(jobId, (id) => restart(id), { immediate: true });
|
||||
|
||||
onUnmounted(() => watcher.dispose());
|
||||
|
||||
return { stdout, stderr, error };
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createTestingPinia } from "@pinia/testing";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { HttpResponse } from "msw";
|
||||
import { setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { defineComponent, ref } from "vue";
|
||||
|
||||
import { useServerMock } from "@/api/client/__mocks__";
|
||||
|
||||
import { useRerunnableTool } from "./rerunnableTool";
|
||||
|
||||
const { server, http } = useServerMock();
|
||||
|
||||
/** The tool store fetches via axios, whose responses need more than a microtask flush. */
|
||||
function settleRequests() {
|
||||
return new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
function mountRerunnableTool(toolId: ReturnType<typeof ref<string | undefined>>) {
|
||||
let result: ReturnType<typeof useRerunnableTool> | undefined;
|
||||
mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = useRerunnableTool(toolId);
|
||||
return () => null;
|
||||
},
|
||||
}),
|
||||
);
|
||||
return result!;
|
||||
}
|
||||
|
||||
describe("useRerunnableTool", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ createSpy: vi.fn, stubActions: false }));
|
||||
});
|
||||
|
||||
it("settles to an error when the tool can't be fetched", async () => {
|
||||
server.use(
|
||||
http.untyped.get("/api/tools/:tool_id", () =>
|
||||
HttpResponse.json({ err_msg: "Tool not found", err_code: 404 }, { status: 404 }),
|
||||
),
|
||||
);
|
||||
const { loading, error, canRerun } = mountRerunnableTool(ref<string | undefined>("tool_a"));
|
||||
expect(loading.value).toBe(true);
|
||||
|
||||
await settleRequests();
|
||||
|
||||
expect(loading.value).toBe(false);
|
||||
expect(error.value).toContain("Tool not found");
|
||||
expect(canRerun.value).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores a failure from an earlier fetch after switching away and back to the same tool", async () => {
|
||||
let toolAFetches = 0;
|
||||
let failFirstFetch: () => void = () => {};
|
||||
server.use(
|
||||
http.untyped.get("/api/tools/:tool_id", async ({ params }) => {
|
||||
if (params.tool_id === "tool_a" && ++toolAFetches === 1) {
|
||||
await new Promise<void>((resolve) => (failFirstFetch = resolve));
|
||||
return HttpResponse.json({ err_msg: "Tool not found", err_code: 404 }, { status: 404 });
|
||||
}
|
||||
// Every other fetch stays pending
|
||||
return new Promise<never>(() => {});
|
||||
}),
|
||||
);
|
||||
const toolId = ref<string | undefined>("tool_a");
|
||||
const { loading, error } = mountRerunnableTool(toolId);
|
||||
await settleRequests();
|
||||
toolId.value = "tool_b";
|
||||
await settleRequests();
|
||||
toolId.value = "tool_a";
|
||||
await settleRequests();
|
||||
|
||||
failFirstFetch();
|
||||
await settleRequests();
|
||||
|
||||
expect(error.value).toBeNull();
|
||||
expect(loading.value).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { computed, type Ref, ref, watch } from "vue";
|
||||
|
||||
import { useToolStore } from "@/stores/toolStore";
|
||||
import { errorMessageAsString } from "@/utils/simple-error";
|
||||
|
||||
/** Resolves whether the tool that ran a job can be rerun (`is_workflow_compatible`), fetching the
|
||||
* tool if it isn't cached yet. A failed fetch (e.g. the tool is no longer installed) settles to
|
||||
* "cannot rerun" with an `error` instead of leaving the check loading forever. */
|
||||
export function useRerunnableTool(toolId: Ref<string | undefined>) {
|
||||
const toolStore = useToolStore();
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const tool = computed(() => (toolId.value ? toolStore.getToolForId(toolId.value) : undefined));
|
||||
const loading = computed(() => !!toolId.value && !tool.value && !error.value);
|
||||
const canRerun = computed(() => !!tool.value?.is_workflow_compatible);
|
||||
|
||||
watch(
|
||||
toolId,
|
||||
async (id, _oldId, onCleanup) => {
|
||||
// Ignore a failure from a fetch started for an earlier tool id, even if the id has since
|
||||
// changed back to the same tool (with a newer fetch still pending).
|
||||
let cancelled = false;
|
||||
onCleanup(() => (cancelled = true));
|
||||
error.value = null;
|
||||
if (!id || toolStore.getToolForId(id)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await toolStore.fetchToolForId(id);
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
error.value = errorMessageAsString(e);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
return { canRerun, loading, error };
|
||||
}
|
||||
@@ -314,6 +314,39 @@ describe("useResourceWatcher", () => {
|
||||
});
|
||||
|
||||
describe("cleanup and resource management", () => {
|
||||
it("should stop polling and remove its visibilitychange listener when disposed", async () => {
|
||||
const { startWatchingResource, dispose } = useResourceWatcher(mockWatchHandler);
|
||||
|
||||
startWatchingResource();
|
||||
await flushPromises();
|
||||
expect(mockWatchHandler).toHaveBeenCalledTimes(1);
|
||||
expect(mockRemoveEventListener).not.toHaveBeenCalled();
|
||||
|
||||
dispose();
|
||||
|
||||
expect(mockRemoveEventListener).toHaveBeenCalledWith("visibilitychange", expect.any(Function));
|
||||
|
||||
// No further polling after disposal.
|
||||
vi.advanceTimersByTime(60000);
|
||||
await flushPromises();
|
||||
expect(mockWatchHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("dispose is idempotent and safe to call more than once", async () => {
|
||||
const { startWatchingResource, dispose } = useResourceWatcher(mockWatchHandler);
|
||||
|
||||
startWatchingResource();
|
||||
await flushPromises();
|
||||
|
||||
dispose();
|
||||
expect(mockRemoveEventListener).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A second dispose (e.g. an unmount hook plus an explicit caller both calling it)
|
||||
// must not throw or remove a listener that's already gone.
|
||||
expect(() => dispose()).not.toThrow();
|
||||
expect(mockRemoveEventListener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should clear existing timeout when starting watching again", async () => {
|
||||
const { startWatchingResource } = useResourceWatcher(mockWatchHandler);
|
||||
|
||||
|
||||
@@ -155,6 +155,10 @@ export function useResourceWatcher<T = unknown>(
|
||||
stopWatchingResource,
|
||||
startWatchingResourceIfNeeded,
|
||||
stopWatchingResourceIfNeeded,
|
||||
/**
|
||||
* Fully tears down this watcher (stops polling and removes its `visibilitychange`
|
||||
* listener). Call once, when this watcher instance is permanently done being used.
|
||||
*/
|
||||
dispose,
|
||||
/**
|
||||
* Reactive boolean ref indicating whether the resource watcher is currently active.
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { computed, type Ref } from "vue";
|
||||
|
||||
import { useJobStore } from "@/stores/jobStore";
|
||||
|
||||
/**
|
||||
* Reactive accessor for a single job's details — shares the jobStore's keyed
|
||||
* cache so multiple consumers with the same id don't refetch. Returns null
|
||||
* when the id is empty or the job hasn't loaded yet.
|
||||
*/
|
||||
export function useJobBasic(jobId: Ref<string | null | undefined>) {
|
||||
const jobStore = useJobStore();
|
||||
const job = computed(() => (jobId.value ? (jobStore.getJob(jobId.value) ?? null) : null));
|
||||
return { job };
|
||||
}
|
||||
@@ -452,7 +452,10 @@ export function getRouter(Galaxy) {
|
||||
{
|
||||
path: "jobs/:jobId/view",
|
||||
component: JobDetails,
|
||||
props: true,
|
||||
props: (route) => ({
|
||||
jobId: route.params.jobId,
|
||||
showHeader: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "object_store_instances/create",
|
||||
|
||||
@@ -0,0 +1,715 @@
|
||||
import { advanceTimersAndFlush } from "@tests/vitest/helpers";
|
||||
import flushPromises from "flush-promises";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useServerMock } from "@/api/client/__mocks__";
|
||||
import type { JobState, ShowFullJobResponse } from "@/api/jobs";
|
||||
|
||||
import { MAX_CACHED_JOBS, useJobStore } from "./jobStore";
|
||||
|
||||
const { server, http } = useServerMock();
|
||||
|
||||
// Cache eviction exercises many requests; API rate limiting has its own tests.
|
||||
vi.mock("@/api/client/rateLimiter", () => ({ createRateLimiterMiddleware: () => ({ onRequest() {} }) }));
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
function buildJob(id: string, state: JobState): ShowFullJobResponse {
|
||||
return {
|
||||
id,
|
||||
state,
|
||||
model_class: "Job",
|
||||
create_time: "2024-01-01T00:00:00",
|
||||
update_time: "2024-01-01T00:00:00",
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
output_collections: {},
|
||||
params: {},
|
||||
tool_id: "cat1",
|
||||
} as ShowFullJobResponse;
|
||||
}
|
||||
|
||||
/** Starts `pollJobUntilTerminal` for each id one at a time, awaiting an MSW flush after each. */
|
||||
async function pollManyJobs(store: ReturnType<typeof useJobStore>, ids: string[]) {
|
||||
for (const id of ids) {
|
||||
store.pollJobUntilTerminal({ id });
|
||||
await flushPromises();
|
||||
}
|
||||
}
|
||||
|
||||
describe("useJobStore", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
it("auto-fetches a job on read via getJob, without going through pollJobUntilTerminal", async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response }) => {
|
||||
callCount++;
|
||||
return response(200).json(buildJob("job0", "ok"));
|
||||
}),
|
||||
);
|
||||
|
||||
const store = useJobStore();
|
||||
// Nothing has polled this id -- a plain read should still trigger a fetch, e.g. for a
|
||||
// one-off, non-polled lookup that never calls pollJobUntilTerminal.
|
||||
expect(store.getJob("job0")).toBeNull();
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1);
|
||||
expect(store.getJob("job0")?.id).toBe("job0");
|
||||
|
||||
// Reading again is satisfied by the cache, not re-fetched.
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("does not start a second polling loop for a job id that is already being polled", async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response }) => {
|
||||
callCount++;
|
||||
return response(200).json(buildJob("job1", "running"));
|
||||
}),
|
||||
);
|
||||
|
||||
const store = useJobStore();
|
||||
store.pollJobUntilTerminal({ id: "job1" });
|
||||
store.pollJobUntilTerminal({ id: "job1" }); // second caller for the same id
|
||||
await flushPromises();
|
||||
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
await advanceTimersAndFlush(1000);
|
||||
// Only one loop ticking: one more fetch, not two.
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
|
||||
it("a stale stopWatchingJob handle from an ended poll does not stop a newer poll for the same id", async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response }) => {
|
||||
callCount++;
|
||||
return response(200).json(buildJob("job9", "running"));
|
||||
}),
|
||||
);
|
||||
|
||||
const store = useJobStore();
|
||||
|
||||
// Consumer A starts a poll and later stops it; this ends the *first* poll entry for
|
||||
// "job9" (refCount drops to 0, entry removed from `activePolls`).
|
||||
const a = store.pollJobUntilTerminal({ id: "job9" });
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1);
|
||||
a.stopWatchingJob();
|
||||
|
||||
// Consumer B starts a brand new poll for the same id: a *second*, independent entry.
|
||||
const b = store.pollJobUntilTerminal({ id: "job9" });
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(2);
|
||||
|
||||
// A's handle is invoked again here (e.g. a component unmounting late, calling its cleanup
|
||||
// a second time, or having queued the call before A originally stopped). If `activePolls`
|
||||
// were keyed only by id, this would decrement *B's* entry (the only one now at "job9") and
|
||||
// could stop it even though B still needs it.
|
||||
a.stopWatchingJob();
|
||||
|
||||
// B's poll must still be running: we keep fetching.
|
||||
await advanceTimersAndFlush(1000);
|
||||
expect(callCount).toBe(3);
|
||||
|
||||
b.stopWatchingJob();
|
||||
await advanceTimersAndFlush(1000);
|
||||
// Now that B has actually released it, the poll really does stop.
|
||||
expect(callCount).toBe(3);
|
||||
});
|
||||
|
||||
it("stops polling once the job reaches a terminal state", async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response }) => {
|
||||
callCount++;
|
||||
return response(200).json(buildJob("job1", "ok"));
|
||||
}),
|
||||
);
|
||||
|
||||
const store = useJobStore();
|
||||
store.pollJobUntilTerminal({ id: "job1" });
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
await advanceTimersAndFlush(5000);
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("does not re-fetch a job that is already cached in a terminal state", async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response }) => {
|
||||
callCount++;
|
||||
return response(200).json(buildJob("job1", "ok"));
|
||||
}),
|
||||
);
|
||||
|
||||
const store = useJobStore();
|
||||
store.pollJobUntilTerminal({ id: "job1" });
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
// Let the loop's next tick see the terminal state and clean itself up.
|
||||
await advanceTimersAndFlush(1000);
|
||||
expect(callCount).toBe(1); // still terminal, no second fetch from that loop
|
||||
|
||||
// Calling again for the same (now terminal, cached) id must not trigger a new fetch --
|
||||
// real job ids never go non-terminal again, so the cache can be trusted permanently.
|
||||
store.pollJobUntilTerminal({ id: "job1" });
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("skips the initial fetch entirely when the job is already cached and terminal", async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response }) => {
|
||||
callCount++;
|
||||
return response(200).json(buildJob("job2", "ok"));
|
||||
}),
|
||||
);
|
||||
|
||||
const store = useJobStore();
|
||||
store.pollJobUntilTerminal({ id: "job2" });
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
// A brand new call for the same id, from a totally separate caller, should be satisfied
|
||||
// by the cache without issuing any request at all.
|
||||
store.pollJobUntilTerminal({ id: "job2" });
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("upgrades a base-only cached job to full when a full: true caller asks for it", async () => {
|
||||
let callCount = 0;
|
||||
let lastFullParam: string | null = null;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response, request }) => {
|
||||
callCount++;
|
||||
lastFullParam = new URL(request.url).searchParams.get("full");
|
||||
return response(200).json(buildJob("job3", "ok"));
|
||||
}),
|
||||
);
|
||||
|
||||
const store = useJobStore();
|
||||
store.pollJobUntilTerminal({ id: "job3", full: false });
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1);
|
||||
expect(lastFullParam).toBe("false");
|
||||
|
||||
// A full: true caller for the same (terminal, base-only cached) id must still fetch --
|
||||
// the cache doesn't yet hold the full shape.
|
||||
store.pollJobUntilTerminal({ id: "job3", full: true });
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(2);
|
||||
expect(lastFullParam).toBe("true");
|
||||
|
||||
// Once upgraded, a later full: false caller is satisfied by the now-full cache.
|
||||
store.pollJobUntilTerminal({ id: "job3", full: false });
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
|
||||
it("does not re-fetch a terminal job that was already cached full via getJob, not pollJobUntilTerminal", async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response }) => {
|
||||
callCount++;
|
||||
return response(200).json(buildJob("job10", "ok"));
|
||||
}),
|
||||
);
|
||||
|
||||
const store = useJobStore();
|
||||
// Cached via getJob's auto-fetch (default full: true), not via pollJobUntilTerminal.
|
||||
// Nothing marks this id as "fully loaded" unless every fetch path does so consistently.
|
||||
store.getJob("job10");
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
// A later poll (default full: true) for the same id must be satisfied by the cache.
|
||||
store.pollJobUntilTerminal({ id: "job10" });
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("does not skip a non-terminal job even if a base version is already cached", async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response }) => {
|
||||
callCount++;
|
||||
return response(200).json(buildJob("job4", "running"));
|
||||
}),
|
||||
);
|
||||
|
||||
const store = useJobStore();
|
||||
store.pollJobUntilTerminal({ id: "job4", full: false });
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
// Still running -- a second caller must keep the poll going, not treat the cache as final.
|
||||
await advanceTimersAndFlush(1000);
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
|
||||
it("upgrades an already-running poll for a non-terminal job to full on its next tick", async () => {
|
||||
let callCount = 0;
|
||||
let lastFullParam: string | null = null;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response, request }) => {
|
||||
callCount++;
|
||||
lastFullParam = new URL(request.url).searchParams.get("full");
|
||||
return response(200).json(buildJob("job5", "running"));
|
||||
}),
|
||||
);
|
||||
|
||||
const store = useJobStore();
|
||||
store.pollJobUntilTerminal({ id: "job5", full: false });
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1);
|
||||
expect(lastFullParam).toBe("false");
|
||||
|
||||
// A full: true caller arrives while that poll is still active (job still running) --
|
||||
// rather than starting a second loop, it should upgrade the existing one in place.
|
||||
store.pollJobUntilTerminal({ id: "job5", full: true });
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1); // no immediate second fetch, just flags the running loop
|
||||
|
||||
await advanceTimersAndFlush(1000);
|
||||
expect(callCount).toBe(2);
|
||||
expect(lastFullParam).toBe("true");
|
||||
});
|
||||
|
||||
it("merges a base response into an already-cached full job instead of overwriting it", async () => {
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response, request }) => {
|
||||
const full = new URL(request.url).searchParams.get("full") === "true";
|
||||
if (full) {
|
||||
return response(200).json({
|
||||
...buildJob("job8", "running"),
|
||||
stdout: "full stdout",
|
||||
stderr: "full stderr",
|
||||
});
|
||||
}
|
||||
// A base (`full=false`) response never carries `stdout`/`stderr` at all -- they're
|
||||
// not merely empty, they're absent from this (differently-shaped) response.
|
||||
const { stdout: _stdout, stderr: _stderr, ...baseJob } = buildJob("job8", "running");
|
||||
return response(200).json(baseJob);
|
||||
}),
|
||||
);
|
||||
|
||||
const store = useJobStore();
|
||||
await store.fetchJob({ id: "job8", full: true });
|
||||
expect(store.getJob("job8")?.stdout).toBe("full stdout");
|
||||
|
||||
// A later base fetch for the same id (e.g. a second, non-full caller) must not clobber
|
||||
// the full-only fields already cached.
|
||||
await store.fetchJob({ id: "job8", full: false });
|
||||
expect(store.getJob("job8")?.stdout).toBe("full stdout");
|
||||
expect(store.getJob("job8")?.stderr).toBe("full stderr");
|
||||
// The base response's own fields (e.g. `state`) still come through fresh.
|
||||
expect(store.getJob("job8")?.state).toBe("running");
|
||||
});
|
||||
|
||||
it("does not let a full: true request piggyback on a concurrent in-flight full: false request", async () => {
|
||||
let callCount = 0;
|
||||
const fullParamsByCall: (string | null)[] = [];
|
||||
let resolveBaseFetch: (() => void) | undefined;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", async ({ response, request }) => {
|
||||
callCount++;
|
||||
const full = new URL(request.url).searchParams.get("full");
|
||||
fullParamsByCall.push(full);
|
||||
if (full === "false") {
|
||||
// Hold the base request open so a full: true request can race it.
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveBaseFetch = resolve;
|
||||
});
|
||||
}
|
||||
return response(200).json(buildJob("job6", "ok"));
|
||||
}),
|
||||
);
|
||||
|
||||
const store = useJobStore();
|
||||
// Kick off a base fetch and let it start (but not finish) before the full fetch begins.
|
||||
const basePromise = store.fetchJob({ id: "job6", full: false });
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
// While the base fetch is still in flight, a full fetch for the same id comes in -- it
|
||||
// must not be satisfied by the base fetch's (still pending) in-flight promise/result.
|
||||
const fullPromise = store.fetchJob({ id: "job6", full: true });
|
||||
|
||||
resolveBaseFetch?.();
|
||||
await Promise.all([basePromise, fullPromise]);
|
||||
|
||||
expect(callCount).toBe(2);
|
||||
expect(fullParamsByCall).toEqual(["false", "true"]);
|
||||
});
|
||||
|
||||
it("still fetches full once more if the poll was upgraded after an in-flight base tick already started", async () => {
|
||||
let callCount = 0;
|
||||
const fullParamsByCall: (string | null)[] = [];
|
||||
let resolveBaseTick: (() => void) | undefined;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", async ({ response, request }) => {
|
||||
callCount++;
|
||||
const full = new URL(request.url).searchParams.get("full");
|
||||
fullParamsByCall.push(full);
|
||||
// The first tick reports the job still running, so the poll schedules a second
|
||||
// tick; the second tick (still base, since the upgrade hasn't landed yet) is held
|
||||
// open so the upgrade below can land while it's in flight, then reports terminal.
|
||||
if (full === "false" && callCount === 2) {
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveBaseTick = resolve;
|
||||
});
|
||||
return response(200).json(buildJob("job7", "ok"));
|
||||
}
|
||||
return response(200).json(buildJob("job7", "running"));
|
||||
}),
|
||||
);
|
||||
|
||||
const store = useJobStore();
|
||||
store.pollJobUntilTerminal({ id: "job7", full: false });
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
// Start the loop's next tick (still base, since it reads `full` before the upgrade below).
|
||||
await advanceTimersAndFlush(1000);
|
||||
expect(callCount).toBe(2);
|
||||
|
||||
// The upgrade arrives while that base tick is still in flight.
|
||||
store.pollJobUntilTerminal({ id: "job7", full: true });
|
||||
|
||||
// Let the held-open base tick resolve (terminal). It must not stop polling having only
|
||||
// ever fetched the base representation -- it should fetch full once more first.
|
||||
resolveBaseTick?.();
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(3);
|
||||
expect(fullParamsByCall[2]).toBe("true");
|
||||
|
||||
expect(store.getJob("job7")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("stops polling for good after a non-retryable fetch error (e.g. a malformed job id)", async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response }) => {
|
||||
callCount++;
|
||||
return response("4XX").json(
|
||||
{ err_msg: "Wrong id specified, unable to decode.", err_code: 400 },
|
||||
{ status: 400 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const store = useJobStore();
|
||||
store.pollJobUntilTerminal({ id: "bad-id" });
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
// A 400 will never succeed no matter how many times we ask -- must not keep polling.
|
||||
await advanceTimersAndFlush(5000);
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("stops polling once a retryable fetch error has exhausted its retries", async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response }) => {
|
||||
callCount++;
|
||||
return response("5XX").json({ err_msg: "Server error", err_code: 500 }, { status: 500 });
|
||||
}),
|
||||
);
|
||||
|
||||
const store = useJobStore();
|
||||
store.pollJobUntilTerminal({ id: "flaky-id" });
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
// Keeps retrying a transient-looking error for a while...
|
||||
await advanceTimersAndFlush(1000);
|
||||
await advanceTimersAndFlush(1000);
|
||||
await advanceTimersAndFlush(1000);
|
||||
expect(callCount).toBeGreaterThan(1);
|
||||
|
||||
const callsAfterRetries = callCount;
|
||||
// ...but eventually gives up rather than retrying forever.
|
||||
await advanceTimersAndFlush(1000);
|
||||
await advanceTimersAndFlush(1000);
|
||||
await advanceTimersAndFlush(1000);
|
||||
expect(callCount).toBe(callsAfterRetries);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useJobStore eviction", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
it("evicts unused jobs while retaining displayed terminal jobs", async () => {
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response, params }) =>
|
||||
response(200).json(buildJob(params.job_id as string, "ok")),
|
||||
),
|
||||
);
|
||||
const store = useJobStore();
|
||||
const release = store.retainJob("displayed");
|
||||
await store.fetchJob({ id: "displayed" });
|
||||
await pollManyJobs(
|
||||
store,
|
||||
Array.from({ length: MAX_CACHED_JOBS }, (_, i) => `job-${i}`),
|
||||
);
|
||||
|
||||
expect(store.getCachedJob("job-0")).toBeNull();
|
||||
expect(store.getCachedJob(`job-${MAX_CACHED_JOBS - 1}`)?.state).toBe("ok");
|
||||
expect(store.getCachedJob("displayed")?.state).toBe("ok");
|
||||
|
||||
release();
|
||||
await pollManyJobs(
|
||||
store,
|
||||
Array.from({ length: MAX_CACHED_JOBS }, (_, i) => `next-${i}`),
|
||||
);
|
||||
expect(store.getCachedJob("displayed")).toBeNull();
|
||||
});
|
||||
|
||||
it("never evicts a job that is still actively being polled", async () => {
|
||||
let runningJobCallCount = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response, params }) => {
|
||||
if (params.job_id === "still-running") {
|
||||
runningJobCallCount++;
|
||||
return response(200).json(buildJob("still-running", "running"));
|
||||
}
|
||||
return response(200).json(buildJob(params.job_id as string, "ok"));
|
||||
}),
|
||||
);
|
||||
|
||||
const store = useJobStore();
|
||||
|
||||
// Start a poll for a non-terminal job first, so it's the oldest entry once the cache
|
||||
// fills up with terminal jobs after it.
|
||||
const { stopWatchingJob } = store.pollJobUntilTerminal({ id: "still-running" });
|
||||
await flushPromises();
|
||||
expect(runningJobCallCount).toBe(1);
|
||||
|
||||
await pollManyJobs(
|
||||
store,
|
||||
Array.from({ length: MAX_CACHED_JOBS }, (_, i) => `job-${i}`),
|
||||
);
|
||||
|
||||
// Despite being the least-recently-touched entry, "still-running" must survive because
|
||||
// it's still actively polled -- evicting it would silently stop a live poll's caller
|
||||
// from ever seeing further updates.
|
||||
expect(store.getJob("still-running")).not.toBeNull();
|
||||
|
||||
await advanceTimersAndFlush(1000);
|
||||
expect(runningJobCallCount).toBe(2);
|
||||
|
||||
stopWatchingJob();
|
||||
});
|
||||
});
|
||||
|
||||
describe("full job freshness", () => {
|
||||
beforeEach(() => setActivePinia(createPinia()));
|
||||
afterEach(() => vi.clearAllTimers());
|
||||
|
||||
it("fetches final full details after a base poll observes completion", async () => {
|
||||
let completed = false;
|
||||
const fullRequests: boolean[] = [];
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response, request }) => {
|
||||
const full = new URL(request.url).searchParams.get("full") === "true";
|
||||
fullRequests.push(full);
|
||||
const job = buildJob("job", completed ? "ok" : "running");
|
||||
return response(200).json(full ? { ...job, tool_stdout: completed ? "final output" : "" } : job);
|
||||
}),
|
||||
);
|
||||
const store = useJobStore();
|
||||
await store.fetchJob({ id: "job", full: true });
|
||||
completed = true;
|
||||
store.pollJobUntilTerminal({ id: "job", full: false });
|
||||
await flushPromises();
|
||||
store.pollJobUntilTerminal({ id: "job", full: true });
|
||||
await flushPromises();
|
||||
|
||||
expect(store.getCachedJob("job")?.tool_stdout).toBe("final output");
|
||||
expect(fullRequests).toEqual([true, false, true]);
|
||||
store.pollJobUntilTerminal({ id: "job", full: true });
|
||||
await flushPromises();
|
||||
expect(fullRequests).toEqual([true, false, true]);
|
||||
});
|
||||
|
||||
it("retries a failed full upgrade instead of caching incomplete terminal details", async () => {
|
||||
let fullCalls = 0;
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", ({ response, request }) => {
|
||||
if (new URL(request.url).searchParams.get("full") === "true") {
|
||||
fullCalls++;
|
||||
if (fullCalls === 1) {
|
||||
return response("5XX").json(
|
||||
{ err_msg: "Temporarily unavailable", err_code: 503 },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
return response(200).json({ ...buildJob("job", "ok"), tool_stdout: "final output" });
|
||||
}
|
||||
return response(200).json(buildJob("job", "ok"));
|
||||
}),
|
||||
);
|
||||
const store = useJobStore();
|
||||
await store.fetchJob({ id: "job", full: false });
|
||||
store.pollJobUntilTerminal({ id: "job", full: true });
|
||||
await flushPromises();
|
||||
expect(store.getJobLoadError("job")).toBeTruthy();
|
||||
await advanceTimersAndFlush(1000);
|
||||
expect(store.getCachedJob("job")?.tool_stdout).toBe("final output");
|
||||
expect(store.getJobLoadError("job")).toBeNull();
|
||||
await advanceTimersAndFlush(1000);
|
||||
expect(fullCalls).toBe(2);
|
||||
});
|
||||
|
||||
it("does not let a slow, older response overwrite a newer job state", async () => {
|
||||
let releaseFull: () => void = () => {};
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", async ({ response, request }) => {
|
||||
if (new URL(request.url).searchParams.get("full") === "true") {
|
||||
// The server snapshot was taken while the job was still running, but arrives late
|
||||
await new Promise<void>((resolve) => (releaseFull = resolve));
|
||||
return response(200).json({ ...buildJob("job", "running"), tool_stdout: "partial" });
|
||||
}
|
||||
return response(200).json(buildJob("job", "ok"));
|
||||
}),
|
||||
);
|
||||
const store = useJobStore();
|
||||
const slowFull = store.fetchJob({ id: "job", full: true });
|
||||
await flushPromises();
|
||||
store.pollJobUntilTerminal({ id: "job", full: false });
|
||||
await flushPromises();
|
||||
expect(store.getCachedJob("job")?.state).toBe("ok");
|
||||
|
||||
releaseFull();
|
||||
await slowFull;
|
||||
await advanceTimersAndFlush(3000);
|
||||
|
||||
const job = store.getCachedJob("job");
|
||||
expect(job?.state).toBe("ok");
|
||||
// Fields the cache lacked are still filled in from the older response
|
||||
expect(job?.tool_stdout).toBe("partial");
|
||||
});
|
||||
|
||||
it("does not orphan a newer poll when a superseded poll's final full fetch fails", async () => {
|
||||
let requestCount = 0;
|
||||
let releaseFull: () => void = () => {};
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", async ({ response, request }) => {
|
||||
requestCount++;
|
||||
if (new URL(request.url).searchParams.get("full") === "true") {
|
||||
await new Promise<void>((resolve) => (releaseFull = resolve));
|
||||
return response("4XX").json({ err_msg: "Forbidden", err_code: 403 }, { status: 403 });
|
||||
}
|
||||
return response(200).json(buildJob("job", "ok"));
|
||||
}),
|
||||
);
|
||||
const store = useJobStore();
|
||||
// A base poll is upgraded to full; its base fetch sees completion, so it fetches final full fields
|
||||
const basePoll = store.pollJobUntilTerminal({ id: "job", full: false });
|
||||
const fullPoll = store.pollJobUntilTerminal({ id: "job", full: true });
|
||||
await flushPromises();
|
||||
// Every caller leaves while that full fetch is pending, and a new caller starts a new poll
|
||||
basePoll.stopWatchingJob();
|
||||
fullPoll.stopWatchingJob();
|
||||
const newPoll = store.pollJobUntilTerminal({ id: "job", full: true });
|
||||
await flushPromises();
|
||||
|
||||
releaseFull();
|
||||
await flushPromises();
|
||||
newPoll.stopWatchingJob();
|
||||
const requestsAfterRelease = requestCount;
|
||||
await advanceTimersAndFlush(5000);
|
||||
|
||||
expect(requestCount).toBe(requestsAfterRelease);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a full poll going when its final full response is superseded by a newer base response", async () => {
|
||||
let fullCalls = 0;
|
||||
let releaseFinalFull: () => void = () => {};
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", async ({ response, request }) => {
|
||||
if (new URL(request.url).searchParams.get("full") !== "true") {
|
||||
return response(200).json(buildJob("job", "ok"));
|
||||
}
|
||||
fullCalls++;
|
||||
if (fullCalls === 1) {
|
||||
return response(200).json({ ...buildJob("job", "running"), tool_stdout: "partial" });
|
||||
}
|
||||
if (fullCalls === 2) {
|
||||
await new Promise<void>((resolve) => (releaseFinalFull = resolve));
|
||||
}
|
||||
return response(200).json({ ...buildJob("job", "ok"), tool_stdout: "FINAL OUTPUT" });
|
||||
}),
|
||||
);
|
||||
const store = useJobStore();
|
||||
await store.fetchJob({ id: "job", full: true });
|
||||
// A base poll sees completion after a full consumer joined, so it fetches the final full fields...
|
||||
store.pollJobUntilTerminal({ id: "job", full: false });
|
||||
store.pollJobUntilTerminal({ id: "job", full: true });
|
||||
await flushPromises();
|
||||
// ...but a newer base request is applied before that full response arrives
|
||||
await store.fetchJob({ id: "job", full: false });
|
||||
releaseFinalFull();
|
||||
await flushPromises();
|
||||
|
||||
await advanceTimersAndFlush(1000);
|
||||
|
||||
expect(store.getCachedJob("job")?.tool_stdout).toBe("FINAL OUTPUT");
|
||||
expect(fullCalls).toBe(3);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("stops polling a finished job whose full responses keep being superseded", async () => {
|
||||
let fullRequests = 0;
|
||||
const pendingFull: Array<() => void> = [];
|
||||
server.use(
|
||||
http.get("/api/jobs/{job_id}", async ({ response, request }) => {
|
||||
if (new URL(request.url).searchParams.get("full") !== "true") {
|
||||
return response(200).json(buildJob("job", "ok"));
|
||||
}
|
||||
fullRequests++;
|
||||
await new Promise<void>((resolve) => pendingFull.push(resolve));
|
||||
return response("4XX").json({ err_msg: "Forbidden", err_code: 403 }, { status: 403 });
|
||||
}),
|
||||
);
|
||||
const store = useJobStore();
|
||||
store.pollJobUntilTerminal({ id: "job", full: false });
|
||||
store.pollJobUntilTerminal({ id: "job", full: true });
|
||||
|
||||
for (let tick = 0; tick < 10; tick++) {
|
||||
await flushPromises();
|
||||
// A newer base request is applied before each pending full response arrives
|
||||
await store.fetchJob({ id: "job", full: false });
|
||||
pendingFull.splice(0).forEach((release) => release());
|
||||
await advanceTimersAndFlush(1000);
|
||||
}
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(fullRequests).toBeLessThan(10);
|
||||
});
|
||||
});
|
||||
+328
-35
@@ -1,23 +1,87 @@
|
||||
/**
|
||||
* Requests jobs
|
||||
* Requests response generated by a tool run
|
||||
*/
|
||||
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
import { GalaxyApi } from "@/api";
|
||||
import { type ResponseVal, type ShowFullJobResponse, TERMINAL_STATES } from "@/api/jobs";
|
||||
import { type FetchParams, useKeyedCache } from "@/composables/keyedCache";
|
||||
import { rethrowSimpleWithStatus } from "@/utils/simple-error";
|
||||
import { useResourceWatcher } from "@/composables/resourceWatcher";
|
||||
import { isRetryableApiError, MAX_RETRIES, rethrowSimpleWithStatus } from "@/utils/simple-error";
|
||||
|
||||
interface JobFetchParams {
|
||||
id: string;
|
||||
/** Whether to request the full job representation. Defaults to `true`. */
|
||||
full?: boolean;
|
||||
}
|
||||
|
||||
interface PollEntry {
|
||||
watcher: ReturnType<typeof useResourceWatcher>;
|
||||
full: boolean;
|
||||
refCount: number;
|
||||
}
|
||||
|
||||
/** Cache target; mounted consumers and in-flight requests may temporarily exceed it. */
|
||||
export const MAX_CACHED_JOBS = 40;
|
||||
|
||||
/**
|
||||
* The Job store for managing job data and tool-run responses.
|
||||
*
|
||||
* - Keeps recently used jobs, evicting unused entries above `MAX_CACHED_JOBS`.
|
||||
* Mounted consumers retain their entries even after polling finishes.
|
||||
* - Allows both base and full requests for the same job id to be tracked independently, but merges
|
||||
* the full response into the cached job rather than overwriting it entirely.
|
||||
* - Polls a job until it reaches a terminal state, deduped so multiple callers watching the same
|
||||
* job share one poll.
|
||||
* - Stores the latest tool-run response (used by the post-run "success" view).
|
||||
*/
|
||||
export const useJobStore = defineStore("jobStore", () => {
|
||||
const latestResponse = ref<ResponseVal | null>(null);
|
||||
|
||||
async function fetchJobById(params: FetchParams): Promise<ShowFullJobResponse> {
|
||||
const storedJobs = ref<{ [id: string]: ShowFullJobResponse }>({});
|
||||
const loadingErrors = ref<{ [id: string]: Error }>({});
|
||||
const retryCounts: { [id: string]: number } = {};
|
||||
|
||||
/** Keyed by `id:full` so a base and a full request for the same job id are tracked separately.
|
||||
* Otherwise whichever one resolved first would be (wrongly) treated as satisfying both.
|
||||
*/
|
||||
const loadingRequests = new Map<string, Promise<ShowFullJobResponse | undefined>>();
|
||||
|
||||
/** Counts in-flight requests per job id (a base and a full request can be in flight for the
|
||||
* same id at once, under separate `loadingRequests` keys) so `isLoadingJob` stays true until
|
||||
* *all* of them finish, not just whichever happens to resolve first. */
|
||||
const loadingCountsByJobId = ref<{ [id: string]: number }>({});
|
||||
|
||||
/** Requests are numbered in the order they start. A response is only written to the cache if no
|
||||
* request for the same job that started *later* has already been applied, so a slow response
|
||||
* carrying an older snapshot (e.g. `running`) can't overwrite a newer one (e.g. `ok`). */
|
||||
let requestSequence = 0;
|
||||
const appliedSequenceByJobId: { [id: string]: number } = {};
|
||||
|
||||
function saveLatestResponse(newResponse: ResponseVal) {
|
||||
latestResponse.value = newResponse;
|
||||
}
|
||||
|
||||
/** Keys a request by `id:full` so that base and full **requests** for the same job id are tracked separately. */
|
||||
function requestKey(params: JobFetchParams): string {
|
||||
return `${params.id}:${params.full ?? true}`;
|
||||
}
|
||||
|
||||
/** Whether a failed fetch for `id` is worth retrying: the error is a retryable status and we
|
||||
* haven't already retried it `MAX_RETRIES` times. */
|
||||
function canRetryJob(id: string): boolean {
|
||||
const existingError = loadingErrors.value[id];
|
||||
return !!existingError && isRetryableApiError(existingError) && (retryCounts[id] ?? 0) <= MAX_RETRIES;
|
||||
}
|
||||
|
||||
const getJobLoadError = computed(() => (id: string) => loadingErrors.value[id] ?? null);
|
||||
|
||||
const isLoadingJob = computed(() => (id: string) => (loadingCountsByJobId.value[id] ?? 0) > 0);
|
||||
|
||||
function mergeJob(freshJob: ShowFullJobResponse, existingJob?: ShowFullJobResponse): ShowFullJobResponse {
|
||||
return existingJob ? { ...existingJob, ...freshJob } : freshJob;
|
||||
}
|
||||
|
||||
async function fetchJobFromApi(params: JobFetchParams): Promise<ShowFullJobResponse> {
|
||||
const { data, error, response } = await GalaxyApi().GET("/api/jobs/{job_id}", {
|
||||
params: { path: { job_id: params.id } },
|
||||
query: { full: true },
|
||||
params: { path: { job_id: params.id }, query: { full: params.full ?? true } },
|
||||
});
|
||||
if (error) {
|
||||
rethrowSimpleWithStatus(error, response);
|
||||
@@ -25,33 +89,260 @@ export const useJobStore = defineStore("jobStore", () => {
|
||||
return data;
|
||||
}
|
||||
|
||||
function saveLatestResponse(newResponse: ResponseVal) {
|
||||
latestResponse.value = newResponse;
|
||||
}
|
||||
|
||||
const {
|
||||
fetchItemById: fetchJob,
|
||||
getItemById: getJob,
|
||||
getItemLoadError: getJobLoadError,
|
||||
isLoadingItem: isLoadingJob,
|
||||
} = useKeyedCache<ShowFullJobResponse>(fetchJobById);
|
||||
|
||||
function pollJobUntilTerminal(params: FetchParams) {
|
||||
function poll() {
|
||||
fetchJob(params);
|
||||
setTimeout(pollJobUntilTerminal, 1000, params);
|
||||
/** Fetches a job, deduping concurrent requests for the same id+`full` shape, merging the
|
||||
* result into the cache, and tracking loading/retry state. */
|
||||
async function fetchAndCacheJob(params: JobFetchParams): Promise<ShowFullJobResponse | undefined> {
|
||||
const key = requestKey(params);
|
||||
const inFlight = loadingRequests.get(key);
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
const job = getJob.value(params.id);
|
||||
if (job) {
|
||||
const jobState = job.state;
|
||||
if (TERMINAL_STATES.indexOf(jobState) !== -1) {
|
||||
return;
|
||||
} else {
|
||||
poll();
|
||||
// Increment the count of in-flight requests for this job id. This ensures that `isLoadingJob`
|
||||
// remains true until all concurrent requests for the same job id (base and full) have completed.
|
||||
loadingCountsByJobId.value[params.id] = (loadingCountsByJobId.value[params.id] ?? 0) + 1;
|
||||
|
||||
const sequence = ++requestSequence;
|
||||
const isStale = () => sequence < (appliedSequenceByJobId[params.id] ?? 0);
|
||||
|
||||
const fetchPromise = (async () => {
|
||||
try {
|
||||
const freshJob = await fetchJobFromApi(params);
|
||||
const existingJob = storedJobs.value[params.id];
|
||||
if (isStale() && existingJob) {
|
||||
// A newer response was already applied: only fill in fields the cache lacks
|
||||
// (e.g. full-only fields), never overwrite the newer state.
|
||||
const job = mergeJob(existingJob, freshJob);
|
||||
storedJobs.value[params.id] = job;
|
||||
return job;
|
||||
}
|
||||
appliedSequenceByJobId[params.id] = Math.max(sequence, appliedSequenceByJobId[params.id] ?? 0);
|
||||
const job = mergeJob(freshJob, existingJob);
|
||||
storedJobs.value[params.id] = job;
|
||||
delete loadingErrors.value[params.id];
|
||||
delete retryCounts[params.id];
|
||||
if (params.full !== false && TERMINAL_STATES.includes(freshJob.state)) {
|
||||
terminalFullJobIds.add(params.id);
|
||||
} else if (params.full !== false || freshJob.state !== existingJob?.state) {
|
||||
terminalFullJobIds.delete(params.id);
|
||||
}
|
||||
return job;
|
||||
} catch (error) {
|
||||
if (isStale()) {
|
||||
// A newer request already succeeded; this failure says nothing about the job now.
|
||||
return storedJobs.value[params.id];
|
||||
}
|
||||
retryCounts[params.id] = (retryCounts[params.id] ?? 0) + 1;
|
||||
loadingErrors.value[params.id] = error as Error;
|
||||
return undefined;
|
||||
} finally {
|
||||
loadingRequests.delete(key);
|
||||
const remaining = (loadingCountsByJobId.value[params.id] ?? 1) - 1;
|
||||
if (remaining <= 0) {
|
||||
delete loadingCountsByJobId.value[params.id];
|
||||
} else {
|
||||
loadingCountsByJobId.value[params.id] = remaining;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
poll();
|
||||
})();
|
||||
|
||||
loadingRequests.set(key, fetchPromise);
|
||||
return fetchPromise;
|
||||
}
|
||||
|
||||
function removeJob(id: string) {
|
||||
delete storedJobs.value[id];
|
||||
delete loadingErrors.value[id];
|
||||
delete retryCounts[id];
|
||||
delete appliedSequenceByJobId[id];
|
||||
}
|
||||
|
||||
// LRU tracking for `MAX_CACHED_JOBS`: a Map's keys iterate oldest-to-newest, and re-`set`ting
|
||||
// a key moves it to the end, so this alone tracks recency order without extra bookkeeping.
|
||||
const lruOrder = new Map<string, true>();
|
||||
|
||||
function markUsed(id: string) {
|
||||
lruOrder.delete(id);
|
||||
lruOrder.set(id, true);
|
||||
}
|
||||
|
||||
const consumers = new Map<string, number>();
|
||||
|
||||
/** Protects a displayed job from eviction until the returned release function is called. */
|
||||
function retainJob(id: string) {
|
||||
consumers.set(id, (consumers.get(id) ?? 0) + 1);
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
const remaining = (consumers.get(id) ?? 1) - 1;
|
||||
if (remaining > 0) {
|
||||
consumers.set(id, remaining);
|
||||
} else {
|
||||
consumers.delete(id);
|
||||
}
|
||||
evictIfOverCap();
|
||||
};
|
||||
}
|
||||
|
||||
function evictIfOverCap() {
|
||||
for (const id of lruOrder.keys()) {
|
||||
if (lruOrder.size <= MAX_CACHED_JOBS) {
|
||||
break;
|
||||
}
|
||||
if (consumers.has(id) || activePolls.has(id) || isLoadingJob.value(id)) {
|
||||
continue;
|
||||
}
|
||||
lruOrder.delete(id);
|
||||
removeJob(id);
|
||||
terminalFullJobIds.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** An overloaded fetch function that ensures the job is retrieved from the cache, but with the
|
||||
* additional, optional `full` option for requesting the full job representation. */
|
||||
async function fetchJob(params: JobFetchParams) {
|
||||
markUsed(params.id);
|
||||
const job = await fetchAndCacheJob(params);
|
||||
evictIfOverCap();
|
||||
return job;
|
||||
}
|
||||
|
||||
/** Reads the cache without starting a request. */
|
||||
const getCachedJob = computed(() => (id: string) => {
|
||||
const job = storedJobs.value[id] ?? null;
|
||||
if (job) {
|
||||
markUsed(id);
|
||||
}
|
||||
return job;
|
||||
});
|
||||
|
||||
/** One-off lookup. Mounted readers use `useJobDetails` to retain their cache entries. */
|
||||
const getJob = computed(() => (id: string) => {
|
||||
const job = getCachedJob.value(id);
|
||||
if (!job && id && !loadingRequests.has(requestKey({ id })) && !getJobLoadError.value(id)) {
|
||||
fetchJob({ id });
|
||||
}
|
||||
return job;
|
||||
});
|
||||
|
||||
// Full-only fields are final only when a full response itself observed completion.
|
||||
const terminalFullJobIds = new Set<string>();
|
||||
|
||||
/** A track of all active polls (by `job_id` and whether the stored representation is full),
|
||||
* plus a count of how many callers still want that poll running, so we don't duplicate polls
|
||||
* for the same ID and stop polling once the last interested caller goes away. */
|
||||
const activePolls = new Map<string, PollEntry>();
|
||||
|
||||
/**
|
||||
* Polls a job until it reaches a terminal state. If the job is already terminal and cached,
|
||||
* it may not poll at all. If the stored job is not a full representation and a full one is
|
||||
* requested, it will poll for the final full representation, including retries on transient errors.
|
||||
*
|
||||
* Returns a `stopWatchingJob` function the caller must invoke (e.g. from `onUnmounted`) once it
|
||||
* no longer needs this job polled because the underlying poll only actually stops once every caller
|
||||
* that started it has done so.
|
||||
*/
|
||||
function pollJobUntilTerminal(params: JobFetchParams): { stopWatchingJob: () => void } {
|
||||
const { id, full = true } = params;
|
||||
const stopWatchingReturn = { stopWatchingJob: () => {} };
|
||||
|
||||
// Not using `getJob` here because that would trigger a fetch if the job isn't already cached
|
||||
const cachedJob = storedJobs.value[id];
|
||||
const cachedJobIsTerminal = !!cachedJob && TERMINAL_STATES.indexOf(cachedJob.state) !== -1;
|
||||
|
||||
/** Whether the cached job satisfies the current request, considering the `full` option. */
|
||||
const cacheSatisfiesRequest = full ? terminalFullJobIds.has(id) : !!cachedJob;
|
||||
if (cachedJobIsTerminal && cacheSatisfiesRequest) {
|
||||
// Already have everything this call needs, and the job won't change anymore.
|
||||
return stopWatchingReturn;
|
||||
}
|
||||
|
||||
const runningPoll = activePolls.get(id);
|
||||
if (runningPoll !== undefined) {
|
||||
runningPoll.refCount++;
|
||||
// We need to switch an ongoing non-`full` poll to request `full`.
|
||||
if (full && !runningPoll.full) {
|
||||
runningPoll.full = true;
|
||||
}
|
||||
return { stopWatchingJob: () => stopWatchingJob(id, runningPoll) };
|
||||
}
|
||||
|
||||
/** Stops this poll. Only removes its own `activePolls` entry: by the time an `await` resolves,
|
||||
* the last caller may have stopped it and a new, unrelated poll may have taken its place. */
|
||||
function endPoll() {
|
||||
watcher.dispose();
|
||||
if (activePolls.get(id) === pollEntry) {
|
||||
activePolls.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Ticks this poll has seen the job terminal without fresh full details yet (see below). */
|
||||
let terminalTicksWithoutFull = 0;
|
||||
|
||||
const watcher = useResourceWatcher(
|
||||
async () => {
|
||||
// Read (not close over) the current requested level as a later call may have
|
||||
// upgraded this poll to `full: true` since it started.
|
||||
const requestFull = activePolls.get(id)?.full ?? full;
|
||||
let job = await fetchJob({ id, full: requestFull });
|
||||
if (activePolls.get(id) !== pollEntry) {
|
||||
endPoll();
|
||||
return;
|
||||
}
|
||||
if (job && TERMINAL_STATES.indexOf(job.state) !== -1) {
|
||||
// An in-flight base request may finish after a full consumer joins.
|
||||
// Fetch final full fields before stopping the shared poll.
|
||||
if (pollEntry.full && !terminalFullJobIds.has(id)) {
|
||||
job = await fetchJob({ id, full: true });
|
||||
if (activePolls.get(id) !== pollEntry) {
|
||||
endPoll();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// A full poll is only done once a (non-stale) full response has observed completion;
|
||||
// otherwise keep polling so the next tick fetches the final full fields again. Cap
|
||||
// that, so full responses that keep being superseded can't poll a finished job forever.
|
||||
const satisfied = !pollEntry.full || terminalFullJobIds.has(id);
|
||||
if (job && TERMINAL_STATES.includes(job.state)) {
|
||||
if (satisfied || ++terminalTicksWithoutFull > MAX_RETRIES) {
|
||||
endPoll();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!job && getJobLoadError.value(id) && !canRetryJob(id)) {
|
||||
// Stop fetching if the fetch failed with a non-retryable error
|
||||
endPoll();
|
||||
}
|
||||
},
|
||||
{ shortPollingInterval: 1000, longPollingInterval: 1000 },
|
||||
);
|
||||
const pollEntry = { watcher, full, refCount: 1 };
|
||||
activePolls.set(id, pollEntry);
|
||||
watcher.startWatchingResource();
|
||||
return { stopWatchingJob: () => stopWatchingJob(id, pollEntry) };
|
||||
}
|
||||
|
||||
/** One caller is done watching a job; the poll stops once refCount hits 0.
|
||||
*
|
||||
* `entry` is the specific poll entry this caller's handle was issued for.
|
||||
* If `id`'s current entry in `activePolls` is a *different* one (the original poll already ended
|
||||
* and a new one started for the same id since), this is a stale handle and must be a no-op:
|
||||
* decrementing the new, unrelated poll's `refCount` could stop it while a still-active caller
|
||||
* still needs it.
|
||||
*/
|
||||
function stopWatchingJob(id: string, entry: PollEntry) {
|
||||
const runningPoll = activePolls.get(id);
|
||||
if (!runningPoll || runningPoll !== entry) {
|
||||
return;
|
||||
}
|
||||
runningPoll.refCount--;
|
||||
if (runningPoll.refCount <= 0) {
|
||||
runningPoll.watcher.dispose();
|
||||
activePolls.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +350,8 @@ export const useJobStore = defineStore("jobStore", () => {
|
||||
fetchJob,
|
||||
saveLatestResponse,
|
||||
getJob,
|
||||
getCachedJob,
|
||||
retainJob,
|
||||
getJobLoadError,
|
||||
isLoadingJob,
|
||||
latestResponse,
|
||||
|
||||
@@ -181,9 +181,9 @@ export const useToolStore = defineStore("toolStore", () => {
|
||||
});
|
||||
|
||||
const getToolNameById = computed(() => {
|
||||
return (toolId: string) => {
|
||||
return (toolId: string, defaultValue = "...") => {
|
||||
const details = toolsById.value[toolId];
|
||||
return details?.name || "...";
|
||||
return details?.name || defaultValue;
|
||||
};
|
||||
});
|
||||
|
||||
@@ -378,7 +378,8 @@ export const useToolStore = defineStore("toolStore", () => {
|
||||
}
|
||||
|
||||
function saveToolForId(toolId: string, toolData: Tool) {
|
||||
toolsById.value[toolId] = toolData;
|
||||
// `toolsById` is a `shallowRef`, so only reassigning `.value` notifies watchers.
|
||||
toolsById.value = { ...toolsById.value, [toolId]: toolData };
|
||||
}
|
||||
|
||||
function saveToolResults(whooshQuery: string, toolsData: Array<string>) {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { getFakeRegisteredUser } from "@tests/test-data";
|
||||
import flushPromises from "flush-promises";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { useServerMock } from "@/api/client/__mocks__";
|
||||
import { useUserStore } from "@/stores/userStore";
|
||||
|
||||
const { server, http } = useServerMock();
|
||||
|
||||
describe("userStore", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
@@ -56,4 +61,88 @@ describe("userStore", () => {
|
||||
expect(userStore.recentTools).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDecodedId", () => {
|
||||
let callCount = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.get("/api/configuration/decode/{encoded_id}", ({ response }) => {
|
||||
callCount++;
|
||||
return response(200).json({ decoded_id: 123 });
|
||||
}),
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
callCount = 0;
|
||||
});
|
||||
|
||||
it("returns null and does not fetch for a non-admin user", async () => {
|
||||
const userStore = useUserStore();
|
||||
userStore.currentUser = getFakeRegisteredUser({ is_admin: false });
|
||||
|
||||
expect(userStore.getDecodedId("abc")).toBeNull();
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(0);
|
||||
});
|
||||
|
||||
it("decodes and caches the id for an admin user", async () => {
|
||||
const userStore = useUserStore();
|
||||
userStore.currentUser = getFakeRegisteredUser({ is_admin: true });
|
||||
|
||||
expect(userStore.getDecodedId("abc")).toBeNull(); // not yet resolved
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1);
|
||||
expect(userStore.getDecodedId("abc")).toBe(123);
|
||||
|
||||
// Second read for the same id should hit the cache, not trigger another fetch
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("retries once the current user becomes known, instead of setting the ID prematurely", async () => {
|
||||
const userStore = useUserStore();
|
||||
// currentUser starts as `null` (not yet loaded), `isAdmin = false`, but this
|
||||
// must not be treated as a confirmed "not admin" and cached as such.
|
||||
expect(userStore.currentUser).toBeNull();
|
||||
expect(userStore.getDecodedId("abc")).toBeNull();
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(0); // never fetched while the user is unknown
|
||||
|
||||
// The user loads in and turns out to be an admin.
|
||||
userStore.currentUser = getFakeRegisteredUser({ is_admin: true });
|
||||
expect(userStore.getDecodedId("abc")).toBeNull(); // not yet resolved
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1);
|
||||
expect(userStore.getDecodedId("abc")).toBe(123);
|
||||
});
|
||||
|
||||
it("falls back to the bootstrapped Galaxy user in embedded views, where the store's user is never loaded", async () => {
|
||||
const windowWithGalaxy = window as unknown as { _galaxyInstance?: unknown };
|
||||
windowWithGalaxy._galaxyInstance = { user: { isAdmin: () => true } };
|
||||
try {
|
||||
const userStore = useUserStore();
|
||||
expect(userStore.currentUser).toBeNull();
|
||||
|
||||
expect(userStore.getDecodedId("abc")).toBeNull(); // not yet resolved
|
||||
await flushPromises();
|
||||
expect(callCount).toBe(1);
|
||||
expect(userStore.getDecodedId("abc")).toBe(123);
|
||||
} finally {
|
||||
delete windowWithGalaxy._galaxyInstance;
|
||||
}
|
||||
});
|
||||
|
||||
it("clears cached decoded ids on $reset so a new user doesn't see a stale result", async () => {
|
||||
const userStore = useUserStore();
|
||||
userStore.currentUser = getFakeRegisteredUser({ is_admin: true });
|
||||
userStore.getDecodedId("abc");
|
||||
await flushPromises();
|
||||
expect(userStore.getDecodedId("abc")).toBe(123);
|
||||
|
||||
userStore.$reset();
|
||||
|
||||
expect(userStore.getDecodedId("abc")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,9 @@ import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
import { type AnyUser, isAdminUser, isAnonymousUser, isRegisteredUser, type RegisteredUser } from "@/api";
|
||||
import { getGalaxyInstance } from "@/app";
|
||||
import { useHashedUserId } from "@/composables/hashedUserId";
|
||||
import { useKeyedCache } from "@/composables/keyedCache";
|
||||
import { useUserLocalStorageFromHashId } from "@/composables/userLocalStorageFromHashedId";
|
||||
import { useHistoryStore } from "@/stores/historyStore";
|
||||
import { useQuotaUsageStore } from "@/stores/quotaUsageStore";
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
addFavoriteEdamTopicQuery,
|
||||
addFavoriteTagQuery,
|
||||
addFavoriteToolQuery,
|
||||
decodeIdById,
|
||||
type FavoriteOrderEntry,
|
||||
type FavoriteSummary,
|
||||
getCurrentUser,
|
||||
@@ -95,6 +98,7 @@ export const useUserStore = defineStore("userStore", () => {
|
||||
currentPreferences.value = null;
|
||||
recentTools.value = [];
|
||||
loadPromise = null;
|
||||
decodedIdCache.value = {};
|
||||
}
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
@@ -359,6 +363,30 @@ export const useUserStore = defineStore("userStore", () => {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Whether the user may decode ids (is an admin), or `null` while that is still unknown.
|
||||
* Embedded views (e.g. scratchbook iframes) deliberately leave `currentUser` unloaded, so fall
|
||||
* back to the user the legacy Galaxy instance bootstrapped from `/context`. */
|
||||
function canDecodeIds(): boolean | null {
|
||||
if (currentUser.value !== null) {
|
||||
return isAdmin.value;
|
||||
}
|
||||
const legacyUser = getGalaxyInstance()?.user;
|
||||
return legacyUser ? Boolean(legacyUser.isAdmin()) : null;
|
||||
}
|
||||
|
||||
async function fetchdecodedIdById(params: { id: string }) {
|
||||
if (canDecodeIds()) {
|
||||
return await decodeIdById(params.id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const { getItemById: getDecodedId, storedItems: decodedIdCache } = useKeyedCache<number | null>(
|
||||
fetchdecodedIdById,
|
||||
// Only fetch a decoded id if the user is known and the item is undefined (not yet cached).
|
||||
() => (item?: number | null) => canDecodeIds() !== null && item === undefined,
|
||||
);
|
||||
|
||||
return {
|
||||
currentUser,
|
||||
currentPreferences,
|
||||
@@ -367,6 +395,7 @@ export const useUserStore = defineStore("userStore", () => {
|
||||
currentTheme,
|
||||
currentFavorites,
|
||||
currentListViewPreferences,
|
||||
getDecodedId,
|
||||
hasSeenUploadHelp,
|
||||
historyPanelWidth,
|
||||
chatPanelWidth,
|
||||
|
||||
@@ -175,3 +175,17 @@ export async function updateFavoriteOrderQuery(userId: string, order: FavoriteOr
|
||||
|
||||
return data as FavoriteSummary;
|
||||
}
|
||||
|
||||
export async function decodeIdById(id: string) {
|
||||
const { data, error } = await GalaxyApi().GET("/api/configuration/decode/{encoded_id}", {
|
||||
params: {
|
||||
path: { encoded_id: id },
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
rethrowSimple(error);
|
||||
}
|
||||
|
||||
return data.decoded_id as number;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*
|
||||
* Usage: mount(Component, { global: getLocalVue() })
|
||||
*/
|
||||
import flushPromises from "flush-promises";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { expect, vi } from "vitest";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
@@ -289,3 +290,13 @@ export function mockUnprivilegedToolsRequest(server, http) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances Vitest's fake timers by `ms`, then flushes promises.
|
||||
* Requires `vi.useFakeTimers()` to have been called in the test file.
|
||||
* @example await advanceTimersAndFlush(1000);
|
||||
*/
|
||||
export async function advanceTimersAndFlush(ms) {
|
||||
vi.advanceTimersByTime(ms);
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user