@artisan-cloud/plugin-framework-admin 0.0.11 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/layer/app/components/powerx/PXRegressionRunDetail.vue +200 -0
- package/layer/app/components/powerx/PXRegressionWorkbench.vue +235 -0
- package/layer/app/composables/regressionComparison.ts +73 -0
- package/layer/app/composables/usePowerXRegression.ts +56 -0
- package/layer/app/types/regression.ts +26 -0
- package/layer/assets/css/sources.css +1 -0
- package/package.json +4 -2
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, watch } from 'vue';
|
|
3
|
+
import { useI18n } from '#imports';
|
|
4
|
+
import type { AssertionResult, CaseDefinition, CaseExecution, Evidence, FixtureResource, RunDetail } from '@artisan-cloud/plugin-framework-client';
|
|
5
|
+
import type { RegressionCasePresentation } from '../../types/regression';
|
|
6
|
+
import { regressionComparison } from '../../composables/regressionComparison';
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
detail: RunDetail; canInvoke: boolean; canManage: boolean; busy: boolean; disconnected: boolean;
|
|
10
|
+
describeCase: (definition: CaseDefinition) => RegressionCasePresentation | undefined;
|
|
11
|
+
}>();
|
|
12
|
+
const emit = defineEmits<{ back: []; cancel: []; cleanup: []; rerun: []; report: []; reconnect: [] }>();
|
|
13
|
+
const { t, locale } = useI18n();
|
|
14
|
+
type Section = 'overview' | 'checks' | 'resources' | 'diagnostics';
|
|
15
|
+
const section = ref<Section>('overview');
|
|
16
|
+
const selectedCase = ref('');
|
|
17
|
+
const comparisonId = ref('');
|
|
18
|
+
const comparisonOpen = ref(false);
|
|
19
|
+
const evidenceOpen = ref(false);
|
|
20
|
+
const viewedEvidence = ref<Evidence[]>([]);
|
|
21
|
+
const technicalOpen = ref(false);
|
|
22
|
+
const heading = ref<HTMLElement | null>(null);
|
|
23
|
+
defineExpose({ focus: () => heading.value?.focus() });
|
|
24
|
+
const status = (value: string) => t('regression.states.' + value);
|
|
25
|
+
const color = (value: string) => value === 'passed' || value === 'succeeded' ? 'success' : value === 'failed' ? 'error' : value === 'blocked' ? 'warning' : 'neutral';
|
|
26
|
+
const assertions = (result: CaseExecution) => result.steps.flatMap(step => step.assertions);
|
|
27
|
+
const current = computed(() => props.detail.case_executions.find(item => item.uuid === selectedCase.value));
|
|
28
|
+
const currentChecks = computed(() => current.value ? assertions(current.value) : []);
|
|
29
|
+
const selectedAssertion = computed(() => currentChecks.value.find(item => item.uuid === comparisonId.value));
|
|
30
|
+
const active = computed(() => ['queued', 'running', 'cancelling'].includes(props.detail.run.phase));
|
|
31
|
+
const headline = computed(() => t('regression.results.headlines.' + (props.detail.run.phase === 'completed' ? props.detail.run.verdict : props.detail.run.phase)));
|
|
32
|
+
const allChecks = computed(() => props.detail.case_executions.flatMap(assertions));
|
|
33
|
+
const requiredCount = computed(() => props.detail.case_executions.reduce((total, result) => total + result.definition_snapshot.required_assertion_keys.length, 0));
|
|
34
|
+
const passedChecks = computed(() => allChecks.value.filter(item => item.required && item.status === 'passed').length);
|
|
35
|
+
const allResources = computed(() => props.detail.case_executions.flatMap(item => item.fixture_resources));
|
|
36
|
+
const cleanCount = computed(() => allResources.value.filter(item => item.cleanup_state === 'succeeded').length);
|
|
37
|
+
const counts = computed(() => ['passed', 'failed', 'blocked', 'pending'].map(verdict => ({ verdict, count: props.detail.case_executions.filter(item => item.verdict === verdict).length })).filter(item => item.count));
|
|
38
|
+
const rerunnable = computed(() => !active.value && props.detail.case_executions.some(item => item.verdict !== 'passed' && item.verdict !== 'pending'));
|
|
39
|
+
const firstFailure = computed(() => {
|
|
40
|
+
if (['cancelling', 'cancelled'].includes(props.detail.run.phase)) return undefined;
|
|
41
|
+
for (const result of props.detail.case_executions) {
|
|
42
|
+
const step = result.steps.find(item => item.status === 'failed' || item.status === 'blocked');
|
|
43
|
+
if (step) return { result, step, assertion: step.assertions.find(item => item.status === 'failed' || item.status === 'blocked') };
|
|
44
|
+
if (result.missing_assertion_keys.length) return { result, step: undefined, assertion: undefined };
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
const failureLocation = computed(() => firstFailure.value ? firstFailure.value.step ? t('regression.results.failureLocation', { name: t(firstFailure.value.result.definition_snapshot.title_key), step: t(firstFailure.value.step.title_key) }) : t('regression.errors.REGRESSION_ASSERTION_MISSING') : '');
|
|
48
|
+
const sectionItems = computed(() => [
|
|
49
|
+
{ label: t('regression.resultSections.overview'), value: 'overview', icon: 'i-heroicons-chart-bar-square' },
|
|
50
|
+
{ label: t('regression.resultSections.checks'), value: 'checks', icon: 'i-heroicons-clipboard-document-check' },
|
|
51
|
+
{ label: t('regression.resultSections.resources'), value: 'resources', icon: 'i-heroicons-circle-stack' },
|
|
52
|
+
{ label: t('regression.resultSections.diagnostics'), value: 'diagnostics', icon: 'i-heroicons-command-line' },
|
|
53
|
+
]);
|
|
54
|
+
const caseItems = computed(() => props.detail.case_executions.map(item => ({ value: item.uuid, label: t(item.definition_snapshot.title_key) })));
|
|
55
|
+
const resourceGaps = computed(() => current.value?.fixture_resources.some(item => !item.display_name?.trim() || !item.role_key || item.cleanup_state === 'succeeded' && !item.cleanup_evidence?.length));
|
|
56
|
+
const missingChecks = computed(() => props.detail.case_executions.some(item => item.missing_assertion_keys.length));
|
|
57
|
+
watch(() => props.detail.run.uuid, () => { section.value = 'overview'; selectedCase.value = ''; comparisonId.value = ''; comparisonOpen.value = false; evidenceOpen.value = false; technicalOpen.value = false; }, { immediate: true });
|
|
58
|
+
watch(() => props.detail.case_executions.map(item => item.uuid), ids => { if (!ids.includes(selectedCase.value)) selectedCase.value = props.detail.case_executions.find(item => item.verdict === 'failed' || item.verdict === 'blocked')?.uuid || ids[0] || ''; }, { immediate: true });
|
|
59
|
+
function comparison(assertion: AssertionResult) {
|
|
60
|
+
if (!current.value) return null;
|
|
61
|
+
return regressionComparison(assertion, props.describeCase(current.value.definition_snapshot)?.results?.[assertion.key], t);
|
|
62
|
+
}
|
|
63
|
+
const comparedRows = computed(() => selectedAssertion.value ? comparison(selectedAssertion.value) : null);
|
|
64
|
+
function inspect(result: CaseExecution) { selectedCase.value = result.uuid; section.value = 'checks'; }
|
|
65
|
+
function inspectCleanup() {
|
|
66
|
+
const result = props.detail.case_executions.find(item => item.cleanup_state === 'failed');
|
|
67
|
+
if (result) selectedCase.value = result.uuid;
|
|
68
|
+
section.value = 'resources';
|
|
69
|
+
}
|
|
70
|
+
function openComparison(assertion: AssertionResult) { comparisonId.value = assertion.uuid; comparisonOpen.value = true; }
|
|
71
|
+
function locateFailure() {
|
|
72
|
+
if (!firstFailure.value) return;
|
|
73
|
+
selectedCase.value = firstFailure.value.result.uuid;
|
|
74
|
+
section.value = firstFailure.value.assertion ? 'checks' : 'diagnostics';
|
|
75
|
+
if (firstFailure.value.assertion) openComparison(firstFailure.value.assertion);
|
|
76
|
+
}
|
|
77
|
+
function showEvidence(evidence: Evidence[]) { viewedEvidence.value = evidence; evidenceOpen.value = true; }
|
|
78
|
+
function blurActive() { if (typeof document !== 'undefined' && document.activeElement instanceof HTMLElement) document.activeElement.blur(); }
|
|
79
|
+
function closeOverlays() { blurActive(); comparisonOpen.value = false; evidenceOpen.value = false; technicalOpen.value = false; }
|
|
80
|
+
function formatTime(value: string | null | undefined) {
|
|
81
|
+
return value ? new Intl.DateTimeFormat(locale.value, { dateStyle: 'short', timeStyle: 'medium' }).format(new Date(value)) : t('regression.states.unrecorded');
|
|
82
|
+
}
|
|
83
|
+
function duration(record: { started_at: string | null; finished_at: string | null }) {
|
|
84
|
+
return record.started_at && record.finished_at ? t('regression.timing.milliseconds', { value: Date.parse(record.finished_at) - Date.parse(record.started_at) }) : t('regression.timing.incomplete');
|
|
85
|
+
}
|
|
86
|
+
function progress(result: CaseExecution) { return t('regression.resultValues.ratio', { passed: assertions(result).filter(item => item.required && item.status === 'passed').length, total: result.definition_snapshot.required_assertion_keys.length }); }
|
|
87
|
+
function source(key: string) { const value = props.detail.run.source_snapshot[key]; return typeof value === 'string' && value.trim() ? value : t('regression.states.unrecorded'); }
|
|
88
|
+
function parentName(resource: FixtureResource) {
|
|
89
|
+
if (!resource.parent_resource_uuid) return t('regression.resultValues.noParent');
|
|
90
|
+
return current.value?.fixture_resources.find(item => item.resource_uuid === resource.parent_resource_uuid)?.display_name?.trim() || t('regression.states.unrecorded');
|
|
91
|
+
}
|
|
92
|
+
function stepName(assertion: AssertionResult) { return current.value?.steps.find(item => item.assertions.some(check => check.uuid === assertion.uuid))?.title_key; }
|
|
93
|
+
</script>
|
|
94
|
+
|
|
95
|
+
<template>
|
|
96
|
+
<section class="min-w-0 space-y-4" data-testid="regression-detail" :data-run-uuid="detail.run.uuid">
|
|
97
|
+
<UButton color="neutral" variant="link" icon="i-heroicons-arrow-left" :label="t('regression.actions.backToRuns')" class="px-0" @click="emit('back')" />
|
|
98
|
+
<header class="rounded-xl border border-default bg-elevated/30 p-4 sm:p-5 space-y-4">
|
|
99
|
+
<div class="flex flex-wrap items-start justify-between gap-3">
|
|
100
|
+
<div class="min-w-0"><h2 ref="heading" tabindex="-1" class="text-xl font-semibold text-highlighted outline-none">{{ headline }}</h2><p class="mt-1 text-sm text-muted">{{ formatTime(detail.run.created_at) }} · {{ t(detail.run.profile_snapshot.title_key) }}</p></div>
|
|
101
|
+
<div class="flex flex-wrap gap-2">
|
|
102
|
+
<UButton v-if="canManage && active" color="neutral" variant="outline" :label="t('regression.actions.cancel')" @click="emit('cancel')" />
|
|
103
|
+
<UButton v-if="canInvoke && rerunnable" :disabled="busy" :label="t('regression.actions.rerun')" @click="emit('rerun')" />
|
|
104
|
+
<UButton color="neutral" variant="outline" icon="i-heroicons-arrow-down-tray" :label="t('regression.actions.report')" @click="emit('report')" />
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
<span class="sr-only" data-testid="regression-verdict">{{ status(detail.run.verdict) }}</span>
|
|
108
|
+
<dl class="grid grid-cols-2 gap-3 xl:grid-cols-4 text-sm">
|
|
109
|
+
<div data-testid="regression-result-counts"><dt class="text-muted">{{ t('regression.resultLabels.cases') }}</dt><dd class="mt-1 font-medium text-highlighted">{{ t('regression.results.total', { count: detail.case_executions.length }) }}</dd><dd class="mt-1 text-xs text-muted"><span v-for="item in counts" :key="item.verdict" class="mr-2">{{ t('regression.results.count', { status: status(item.verdict), count: item.count }) }}</span></dd></div>
|
|
110
|
+
<div><dt class="text-muted">{{ t('regression.resultLabels.checks') }}</dt><dd class="mt-1 text-lg font-semibold text-highlighted" data-testid="regression-required-count">{{ t('regression.resultValues.ratio', { passed: passedChecks, total: requiredCount }) }}</dd></div>
|
|
111
|
+
<div><dt class="text-muted">{{ t('regression.fields.duration') }}</dt><dd class="mt-1 font-medium text-highlighted">{{ duration(detail.run) }}</dd></div>
|
|
112
|
+
<div data-testid="regression-cleanup"><dt class="text-muted">{{ t('regression.fields.cleanup') }}</dt><dd class="mt-1 font-medium" :class="detail.run.cleanup_state === 'failed' ? 'text-error' : 'text-highlighted'">{{ status(detail.run.cleanup_state) }}</dd><dd class="mt-1 text-xs text-muted">{{ t('regression.resultLabels.resourcesCleaned', { passed: cleanCount, total: allResources.length }) }}</dd></div>
|
|
113
|
+
</dl>
|
|
114
|
+
</header>
|
|
115
|
+
<UAlert v-if="failureLocation" color="error" variant="subtle" :title="failureLocation" data-testid="regression-failure-location"><template #actions><UButton color="error" variant="outline" :label="t('regression.resultActions.locateFailure')" @click="locateFailure" /></template></UAlert>
|
|
116
|
+
<UAlert v-if="missingChecks && !failureLocation" color="error" :title="t('regression.errors.REGRESSION_ASSERTION_MISSING')" />
|
|
117
|
+
<UAlert v-if="detail.run.reason_code" :color="detail.run.phase === 'cancelled' ? 'neutral' : 'error'" :title="t('regression.errors.' + detail.run.reason_code)" />
|
|
118
|
+
<UAlert v-if="detail.run.cleanup_state === 'failed'" color="error" :title="t('regression.errors.REGRESSION_CLEANUP_FAILED')"><template #actions><UButton v-if="canManage" color="error" :label="t('regression.actions.cleanup')" @click="emit('cleanup')" /><UButton color="neutral" variant="outline" :label="t('regression.resultActions.inspectResources')" @click="inspectCleanup" /></template></UAlert>
|
|
119
|
+
<UAlert v-if="disconnected" color="warning" :title="t('regression.errors.REGRESSION_STREAM_INTERRUPTED')"><template #actions><UButton :label="t('regression.actions.reconnect')" @click="emit('reconnect')" /></template></UAlert>
|
|
120
|
+
|
|
121
|
+
<div class="min-w-0 lg:grid lg:grid-cols-[190px_minmax(0,1fr)] lg:gap-6" data-testid="regression-result-layout">
|
|
122
|
+
<nav class="hidden lg:block" :aria-label="t('regression.resultLabels.sections')" data-testid="regression-result-nav-desktop">
|
|
123
|
+
<UTabs v-model="section" :items="sectionItems" orientation="vertical" :content="false" variant="pill" :ui="{ list: 'w-full', trigger: 'justify-start' }" />
|
|
124
|
+
</nav>
|
|
125
|
+
<nav class="mb-4 overflow-x-auto lg:hidden" :aria-label="t('regression.resultLabels.sections')" data-testid="regression-result-nav-mobile">
|
|
126
|
+
<UTabs v-model="section" :items="sectionItems" :content="false" variant="link" :ui="{ list: 'w-max min-w-full', trigger: 'shrink-0' }" />
|
|
127
|
+
</nav>
|
|
128
|
+
<div class="min-w-0 rounded-xl border border-default p-4 sm:p-5 space-y-5" data-testid="regression-result-content">
|
|
129
|
+
<template v-if="section === 'overview'">
|
|
130
|
+
<div><h3 class="text-lg font-semibold text-highlighted">{{ t('regression.resultSections.overview') }}</h3><p class="mt-1 text-sm text-muted">{{ t('regression.resultHelp.overview') }}</p></div>
|
|
131
|
+
<div class="divide-y divide-default" data-testid="regression-case-results">
|
|
132
|
+
<article v-for="result in detail.case_executions" :key="result.uuid" class="grid grid-cols-2 gap-3 py-4 md:grid-cols-[minmax(0,2fr)_auto_auto_auto] md:items-center" :data-testid="'regression-case-result-' + result.definition_snapshot.key">
|
|
133
|
+
<div class="col-span-2 min-w-0 md:col-span-1"><h4 class="font-medium text-highlighted">{{ t(result.definition_snapshot.title_key) }}</h4><p class="mt-1 text-sm text-muted">{{ t(result.definition_snapshot.description_key) }}</p></div>
|
|
134
|
+
<div class="text-sm"><UBadge :color="color(result.verdict)" variant="subtle">{{ status(result.verdict) }}</UBadge><p class="mt-1 text-xs text-muted">{{ t('regression.resultLabels.checks') }} {{ progress(result) }}</p></div>
|
|
135
|
+
<div class="text-sm text-muted"><p>{{ duration(result) }}</p><p class="mt-1 text-xs">{{ t('regression.fields.cleanup') }}: {{ status(result.cleanup_state) }}</p></div>
|
|
136
|
+
<UButton color="neutral" variant="outline" class="col-span-2 justify-center md:col-span-1" :label="t('regression.resultActions.inspectChecks')" @click="inspect(result)" />
|
|
137
|
+
</article>
|
|
138
|
+
</div>
|
|
139
|
+
</template>
|
|
140
|
+
<template v-else-if="current">
|
|
141
|
+
<div class="flex flex-wrap items-start justify-between gap-3">
|
|
142
|
+
<div><h3 class="text-lg font-semibold text-highlighted">{{ t('regression.resultSections.' + section) }}</h3><p class="mt-1 text-sm text-muted" data-testid="regression-selected-case">{{ t(current.definition_snapshot.title_key) }}</p></div>
|
|
143
|
+
<UFormField v-if="caseItems.length > 1" :label="t('regression.resultLabels.currentCase')"><USelect v-model="selectedCase" :items="caseItems" class="w-64 max-w-full" :aria-label="t('regression.resultLabels.currentCase')" /></UFormField>
|
|
144
|
+
</div>
|
|
145
|
+
<template v-if="section === 'checks'">
|
|
146
|
+
<p class="text-sm text-muted">{{ t('regression.resultHelp.checks') }}</p>
|
|
147
|
+
<div v-if="!currentChecks.length" class="rounded-lg bg-elevated/50 p-5 text-sm text-muted">{{ t('regression.resultHelp.noChecks') }}</div>
|
|
148
|
+
<article v-for="assertion in currentChecks" :key="assertion.uuid" class="rounded-lg border border-default p-4 space-y-3" :class="assertion.status === 'failed' ? 'border-error/60' : ''" :data-testid="'regression-assertion-' + assertion.key">
|
|
149
|
+
<div class="flex flex-wrap items-start justify-between gap-2"><div><h4 class="font-medium text-highlighted">{{ t(assertion.title_key) }}</h4><p v-if="stepName(assertion)" class="mt-1 text-xs text-muted">{{ t(stepName(assertion)!) }}</p></div><UBadge :color="color(assertion.status)" variant="subtle">{{ status(assertion.status) }}</UBadge></div>
|
|
150
|
+
<UAlert v-if="comparison(assertion) === null" color="warning" :title="t('regression.resultHelp.presentationMissing')" />
|
|
151
|
+
<UAlert v-if="comparison(assertion)?.some(row => !row.recorded)" color="warning" :title="t('regression.resultHelp.valueMissing')" />
|
|
152
|
+
<div v-if="comparison(assertion) !== null && comparison(assertion)!.length <= 4" class="space-y-2">
|
|
153
|
+
<div class="hidden grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)] gap-3 text-xs text-muted sm:grid"><span>{{ t('regression.resultLabels.field') }}</span><span>{{ t('regression.fields.expected') }}</span><span>{{ t('regression.fields.actual') }}</span></div>
|
|
154
|
+
<div v-for="(row, index) in comparison(assertion)!" :key="index" class="grid grid-cols-1 gap-2 rounded-md bg-elevated/40 p-3 text-sm sm:grid-cols-3 sm:gap-3" :class="!row.matches ? 'ring-1 ring-error/40' : ''" data-testid="regression-check-comparison">
|
|
155
|
+
<span class="font-medium text-highlighted">{{ row.label }}</span><div><span class="mr-2 text-xs text-muted sm:hidden">{{ t('regression.fields.expected') }}</span><span class="whitespace-pre-wrap break-words">{{ row.expected }}</span></div><div><span class="mr-2 text-xs text-muted sm:hidden">{{ t('regression.fields.actual') }}</span><span class="whitespace-pre-wrap break-words">{{ row.actual }}</span></div>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
<div v-else-if="comparison(assertion) !== null" class="grid gap-3 text-sm sm:grid-cols-2"><p><span class="text-muted">{{ t('regression.fields.expected') }}: </span>{{ t('regression.resultLabels.expectedFields', { total: comparison(assertion)!.length }) }}</p><p><span class="text-muted">{{ t('regression.fields.actual') }}: </span>{{ t('regression.resultLabels.matchingFields', { passed: comparison(assertion)!.filter(row => row.matches).length, total: comparison(assertion)!.length }) }}</p></div>
|
|
159
|
+
<div class="flex flex-wrap gap-2"><UButton color="neutral" variant="outline" size="sm" :label="t('regression.resultActions.compare')" @click="openComparison(assertion)" /><UButton color="neutral" variant="ghost" size="sm" :label="t('regression.resultActions.evidence')" @click="showEvidence(assertion.evidence)" /></div>
|
|
160
|
+
</article>
|
|
161
|
+
</template>
|
|
162
|
+
<template v-else-if="section === 'resources'">
|
|
163
|
+
<p class="text-sm text-muted">{{ t('regression.resultHelp.resources') }}</p>
|
|
164
|
+
<UAlert v-if="resourceGaps" color="warning" :title="t('regression.resultHelp.resourceGaps')" />
|
|
165
|
+
<div v-if="!current.fixture_resources.length" class="rounded-lg bg-elevated/50 p-5 text-sm text-muted">{{ t('regression.resultHelp.noResources') }}</div>
|
|
166
|
+
<div v-else class="divide-y divide-default" data-testid="regression-resources">
|
|
167
|
+
<article v-for="resource in current.fixture_resources" :key="resource.uuid" class="grid gap-3 py-4 md:grid-cols-[minmax(0,2fr)_minmax(0,1.5fr)_minmax(0,1.5fr)_auto] md:items-start" data-testid="regression-resource-row">
|
|
168
|
+
<div class="min-w-0"><p class="text-xs text-muted">{{ resource.role_key ? t(resource.role_key) : t('regression.states.unrecorded') }}</p><h4 class="mt-1 break-words text-sm font-medium text-highlighted">{{ resource.display_name?.trim() || t('regression.states.unrecorded') }}</h4></div>
|
|
169
|
+
<div class="min-w-0 text-sm"><p class="text-xs text-muted">{{ t('regression.resultLabels.source') }}</p><p class="mt-1 break-words">{{ parentName(resource) }}</p></div>
|
|
170
|
+
<div class="text-sm"><UBadge :color="color(resource.cleanup_state)" variant="subtle">{{ status(resource.cleanup_state) }}</UBadge><p class="mt-1 text-xs text-muted">{{ formatTime(resource.cleaned_at) }}</p><p class="mt-1 text-xs text-muted">{{ resource.cleanup_evidence?.length ? t('regression.resultLabels.readbackRecorded', { count: resource.cleanup_evidence.length }) : t('regression.resultLabels.readbackMissing') }}</p></div>
|
|
171
|
+
<UButton v-if="resource.cleanup_evidence?.length" color="neutral" variant="outline" size="sm" :label="t('regression.resultActions.cleanupEvidence')" @click="showEvidence(resource.cleanup_evidence)" />
|
|
172
|
+
</article>
|
|
173
|
+
</div>
|
|
174
|
+
</template>
|
|
175
|
+
<template v-else-if="section === 'diagnostics'">
|
|
176
|
+
<ol class="space-y-3" data-testid="regression-result-steps">
|
|
177
|
+
<li v-for="(definition, index) in current.definition_snapshot.steps" :key="definition.uuid" class="rounded-lg border border-default p-4 space-y-2">
|
|
178
|
+
<div class="flex items-start justify-between gap-3"><h4 class="font-medium text-highlighted">{{ index + 1 }}. {{ t(definition.title_key) }}</h4><UBadge :color="color(current.steps.find(step => step.step_uuid === definition.uuid)?.status || 'pending')" variant="subtle">{{ status(current.steps.find(step => step.step_uuid === definition.uuid)?.status || 'pending') }}</UBadge></div>
|
|
179
|
+
<template v-for="step in current.steps.filter(item => item.step_uuid === definition.uuid)" :key="step.uuid"><dl class="grid gap-2 text-xs text-muted sm:grid-cols-3"><div><dt>{{ t('regression.fields.started') }}</dt><dd class="mt-1">{{ formatTime(step.started_at) }}</dd></div><div><dt>{{ t('regression.resultLabels.finished') }}</dt><dd class="mt-1">{{ formatTime(step.finished_at) }}</dd></div><div><dt>{{ t('regression.fields.duration') }}</dt><dd class="mt-1">{{ duration(step) }}</dd></div></dl></template>
|
|
180
|
+
</li>
|
|
181
|
+
</ol>
|
|
182
|
+
<dl class="grid gap-3 border-t border-default pt-4 text-sm sm:grid-cols-2" data-testid="regression-run-snapshot"><div><dt class="text-muted">{{ t('regression.fields.pluginVersion') }}</dt><dd class="mt-1">{{ source('plugin_version') }}</dd></div><div><dt class="text-muted">{{ t('regression.fields.sourceRevision') }}</dt><dd class="mt-1 break-words">{{ source('source_revision') }}</dd></div></dl>
|
|
183
|
+
<div v-if="current.dependency_evidence.length" class="space-y-2 text-sm" data-testid="regression-dependency-evidence"><p v-for="dependency in current.dependency_evidence" :key="dependency.dependency_key">{{ t('regression.boundaries.' + dependency.boundary.replaceAll('.', '_')) }} · {{ t('regression.fields.declaredMode') }}: {{ t('regression.dependencyModes.' + dependency.declared_mode) }} · {{ t('regression.fields.actualMode') }}: {{ t('regression.dependencyModes.' + dependency.actual_mode) }}</p></div>
|
|
184
|
+
<UButton color="neutral" variant="outline" :label="t('regression.actions.technical')" data-testid="regression-result-technical" @click="technicalOpen = true" />
|
|
185
|
+
</template>
|
|
186
|
+
</template>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<USlideover v-model:open="comparisonOpen" :title="selectedAssertion ? t(selectedAssertion.title_key) : t('regression.resultActions.compare')" :description="t('regression.resultHelp.comparison')" :ui="{ content: 'max-w-3xl w-full' }">
|
|
191
|
+
<template #body><div class="space-y-4" data-testid="regression-difference"><UAlert v-if="comparedRows === null" color="warning" :title="t('regression.resultHelp.presentationMissing')" /><div v-for="(row, index) in comparedRows || []" :key="index" class="rounded-lg border border-default p-4 space-y-3" :class="!row.matches ? 'border-error/60' : ''"><div class="flex items-start justify-between gap-2"><h4 class="text-sm font-medium text-highlighted">{{ row.label }}</h4><UBadge :color="!row.recorded ? 'warning' : row.matches ? 'success' : 'error'" variant="subtle">{{ t(!row.recorded ? 'regression.states.unrecorded' : row.matches ? 'regression.resultValues.matches' : 'regression.resultValues.differs') }}</UBadge></div><div class="grid gap-3 text-sm sm:grid-cols-2"><div><p class="mb-1 text-xs text-muted">{{ t('regression.fields.expected') }}</p><p class="whitespace-pre-wrap break-words">{{ row.expected }}</p></div><div><p class="mb-1 text-xs text-muted">{{ t('regression.fields.actual') }}</p><p class="whitespace-pre-wrap break-words">{{ row.actual }}</p></div></div></div></div></template>
|
|
192
|
+
<template #footer><UButton color="neutral" variant="outline" :label="t('regression.actions.close')" @click="closeOverlays" /></template>
|
|
193
|
+
</USlideover>
|
|
194
|
+
<USlideover v-model:open="evidenceOpen" :title="t('regression.resultActions.evidence')" :description="t('regression.resultHelp.evidence')" :ui="{ content: 'max-w-3xl w-full' }">
|
|
195
|
+
<template #body><p v-if="!viewedEvidence.length" class="text-sm text-muted">{{ t('regression.resultHelp.noEvidence') }}</p><article v-for="(evidence, index) in viewedEvidence" :key="index" class="mb-5 space-y-2 text-sm"><p>{{ t('regression.fields.boundary') }}: {{ t('regression.boundaries.' + evidence.boundary.replaceAll('.', '_')) }}</p><p class="text-muted">{{ formatTime(evidence.observed_at) }}</p><pre class="overflow-auto rounded-lg bg-elevated p-3 text-xs" data-testid="regression-evidence-snapshot">{{ JSON.stringify(evidence, null, 2) }}</pre></article></template>
|
|
196
|
+
<template #footer><UButton color="neutral" variant="outline" :label="t('regression.actions.close')" @click="closeOverlays" /></template>
|
|
197
|
+
</USlideover>
|
|
198
|
+
<UModal v-model:open="technicalOpen" :title="t('regression.actions.technical')" :description="t('regression.technical.description')" :ui="{ content: 'max-w-4xl' }"><template #body><pre class="overflow-auto rounded-lg bg-elevated p-3 text-xs">{{ JSON.stringify(detail, null, 2) }}</pre></template><template #footer><UButton color="neutral" variant="outline" :label="t('regression.actions.close')" @click="closeOverlays" /></template></UModal>
|
|
199
|
+
</section>
|
|
200
|
+
</template>
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, nextTick, onMounted, ref, toRaw, watch } from 'vue';
|
|
3
|
+
import { useI18n } from '#imports';
|
|
4
|
+
import * as v from 'valibot';
|
|
5
|
+
import type { RegressionClient, CaseDefinition, Run } from '@artisan-cloud/plugin-framework-client';
|
|
6
|
+
import type { RegressionCasePresentation } from '../../types/regression';
|
|
7
|
+
import { usePowerXRegression } from '../../composables/usePowerXRegression';
|
|
8
|
+
import PXRegressionRunDetail from './PXRegressionRunDetail.vue';
|
|
9
|
+
|
|
10
|
+
const props = defineProps<{
|
|
11
|
+
client: RegressionClient;
|
|
12
|
+
canRead: boolean;
|
|
13
|
+
canInvoke: boolean;
|
|
14
|
+
canManage: boolean;
|
|
15
|
+
identityKey?: string;
|
|
16
|
+
defaultInputs: (definition: CaseDefinition) => Record<string, unknown>;
|
|
17
|
+
describeCase: (definition: CaseDefinition) => RegressionCasePresentation | undefined;
|
|
18
|
+
}>();
|
|
19
|
+
const { t } = useI18n();
|
|
20
|
+
const state = usePowerXRegression(props.client);
|
|
21
|
+
const tab = ref('cases');
|
|
22
|
+
const showResult = ref(false);
|
|
23
|
+
const profile = ref('');
|
|
24
|
+
const suite = ref('all');
|
|
25
|
+
const filter = ref('');
|
|
26
|
+
const selected = ref<string[]>([]);
|
|
27
|
+
const inputValues = ref<Record<string, Record<string, any>>>({});
|
|
28
|
+
const caseOpen = ref(false);
|
|
29
|
+
const viewedCase = ref<CaseDefinition | null>(null);
|
|
30
|
+
const preparationOpen = ref(false);
|
|
31
|
+
const preparationIds = ref<string[]>([]);
|
|
32
|
+
const invalidInputCases = ref<string[]>([]);
|
|
33
|
+
const rerunOf = ref<string>();
|
|
34
|
+
const definitionTechnicalOpen = ref(false);
|
|
35
|
+
const resultView = ref<{ focus: () => void } | null>(null);
|
|
36
|
+
const focusAfterPreparation = ref(false);
|
|
37
|
+
const selectedProfile = computed(() => state.profiles.value.find(p => p.uuid === profile.value));
|
|
38
|
+
const tabItems = computed(() => [
|
|
39
|
+
{ label: t('regression.tabs.cases'), value: 'cases', icon: 'i-heroicons-clipboard-document-check' },
|
|
40
|
+
{ label: t('regression.tabs.runs'), value: 'runs', icon: 'i-heroicons-clock' },
|
|
41
|
+
]);
|
|
42
|
+
const profileItems = computed(() => state.profiles.value.map(p => ({ label: t(p.title_key), value: p.uuid })));
|
|
43
|
+
const suiteItems = computed(() => [{ label: t('regression.fields.allSuites'), value: 'all' }, ...state.suites.value.map(s => ({ label: t(s.title_key), value: s.uuid }))]);
|
|
44
|
+
const available = computed(() => selectedProfile.value ? state.cases.value.filter(c => c.supported_profile_kinds.includes(selectedProfile.value!.kind)) : []);
|
|
45
|
+
const visible = computed(() => available.value.filter(c => (suite.value === 'all' || c.suite_uuid === suite.value) && (!filter.value || t(c.title_key).toLowerCase().includes(filter.value.toLowerCase()) || c.tags.some(tag => tag.toLowerCase().includes(filter.value.toLowerCase())))));
|
|
46
|
+
const preparingCases = computed(() => preparationIds.value.map(id => state.cases.value.find(c => c.uuid === id)).filter((c): c is CaseDefinition => !!c));
|
|
47
|
+
const busy = computed(() => state.runs.value.some(run => ['queued', 'running', 'cancelling'].includes(run.phase) || run.cleanup_state === 'running'));
|
|
48
|
+
const visibleSelection = computed(() => visible.value.length > 0 && visible.value.every(item => selected.value.includes(item.uuid)) ? true : selected.value.length ? 'indeterminate' as const : false);
|
|
49
|
+
function selectVisible(value: boolean | 'indeterminate') { selected.value = value === true ? visible.value.map(item => item.uuid) : []; }
|
|
50
|
+
function preparationError(event: { errors: Array<{ name?: string }> }) { invalidInputCases.value = [...new Set(event.errors.map(error => error.name?.split('.')[0]).filter((id): id is string => !!id))]; }
|
|
51
|
+
const status = (value: string) => t('regression.states.' + value);
|
|
52
|
+
const color = (value: string) => value === 'passed' ? 'success' : value === 'failed' ? 'error' : value === 'blocked' ? 'warning' : 'neutral';
|
|
53
|
+
const latestRuns = computed(() => {
|
|
54
|
+
const latest = new Map<string, Run>();
|
|
55
|
+
for (const run of [...state.runs.value].sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))) {
|
|
56
|
+
if (run.profile_uuid !== profile.value) continue;
|
|
57
|
+
for (const definition of run.selection_snapshot) if (!latest.has(definition.uuid)) latest.set(definition.uuid, run);
|
|
58
|
+
}
|
|
59
|
+
return latest;
|
|
60
|
+
});
|
|
61
|
+
function configured(definition: CaseDefinition) {
|
|
62
|
+
const presentation = props.describeCase(definition);
|
|
63
|
+
const properties = definition.input_schema.properties as Record<string, { type?: string }> | undefined;
|
|
64
|
+
if (!presentation || !properties) return false;
|
|
65
|
+
const fields = presentation.fields.map(field => field.key);
|
|
66
|
+
return new Set(fields).size === fields.length && fields.length === Object.keys(properties).length &&
|
|
67
|
+
fields.every(key => properties[key]?.type === 'string');
|
|
68
|
+
}
|
|
69
|
+
const preparationConfigured = computed(() => preparingCases.value.length === preparationIds.value.length && preparingCases.value.every(configured));
|
|
70
|
+
const formSchema = computed(() => {
|
|
71
|
+
const cases: Record<string, any> = {};
|
|
72
|
+
for (const definition of preparingCases.value) {
|
|
73
|
+
const fields: Record<string, any> = {};
|
|
74
|
+
const properties = definition.input_schema.properties as Record<string, { minLength?: number; maxLength?: number; format?: string }>;
|
|
75
|
+
const required = definition.input_schema.required as string[] | undefined;
|
|
76
|
+
for (const field of props.describeCase(definition)?.fields || []) {
|
|
77
|
+
const property = properties[field.key];
|
|
78
|
+
if (!property) continue;
|
|
79
|
+
const rules: any[] = [v.string(t('regression.validation.required', { field: t(field.labelKey) }))];
|
|
80
|
+
if (required?.includes(field.key)) rules.push(v.nonEmpty(t('regression.validation.required', { field: t(field.labelKey) })));
|
|
81
|
+
if (property.minLength) rules.push(v.minLength(property.minLength, t('regression.validation.minLength', { field: t(field.labelKey), count: property.minLength })));
|
|
82
|
+
if (property.maxLength) rules.push(v.maxLength(property.maxLength, t('regression.validation.maxLength', { field: t(field.labelKey), count: property.maxLength })));
|
|
83
|
+
if (property.format === 'uuid') rules.push(v.uuid(t('regression.validation.uuid', { field: t(field.labelKey) })));
|
|
84
|
+
fields[field.key] = v.pipe(...rules as [any, ...any[]]);
|
|
85
|
+
}
|
|
86
|
+
cases[definition.uuid] = v.object(fields);
|
|
87
|
+
}
|
|
88
|
+
return v.object(cases);
|
|
89
|
+
});
|
|
90
|
+
function initializeInputs(definition: CaseDefinition) {
|
|
91
|
+
if (!inputValues.value[definition.uuid]) inputValues.value[definition.uuid] = structuredClone(props.defaultInputs(definition));
|
|
92
|
+
for (const field of props.describeCase(definition)?.fields || []) {
|
|
93
|
+
if (!Object.hasOwn(inputValues.value[definition.uuid], field.key)) inputValues.value[definition.uuid][field.key] = '';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
watch(state.cases, definitions => definitions.forEach(initializeInputs));
|
|
97
|
+
watch(state.profiles, items => { if (!items.some(p => p.uuid === profile.value)) profile.value = items[0]?.uuid || ''; });
|
|
98
|
+
watch(profile, () => { selected.value = []; suite.value = 'all'; filter.value = ''; preparationOpen.value = false; });
|
|
99
|
+
watch(visible, definitions => { selected.value = selected.value.filter(id => definitions.some(c => c.uuid === id)); });
|
|
100
|
+
watch(tab, (_value, previous) => { if (previous === 'runs') showResult.value = false; });
|
|
101
|
+
function clear() {
|
|
102
|
+
state.reset(); tab.value = 'cases'; showResult.value = false; selected.value = []; profile.value = ''; suite.value = 'all'; filter.value = '';
|
|
103
|
+
inputValues.value = {}; caseOpen.value = false; viewedCase.value = null; preparationOpen.value = false; preparationIds.value = []; rerunOf.value = undefined; definitionTechnicalOpen.value = false; focusAfterPreparation.value = false;
|
|
104
|
+
}
|
|
105
|
+
watch(() => props.canRead, value => { clear(); if (value) void state.load(); });
|
|
106
|
+
watch(() => props.identityKey, () => { clear(); if (props.canRead) void state.load(); });
|
|
107
|
+
onMounted(() => { if (props.canRead) void state.load(); });
|
|
108
|
+
function blurActive() { if (typeof document !== 'undefined' && document.activeElement instanceof HTMLElement) document.activeElement.blur(); }
|
|
109
|
+
function viewCase(definition: CaseDefinition) { viewedCase.value = definition; caseOpen.value = true; }
|
|
110
|
+
async function prepare(ids: string[], previous?: string) {
|
|
111
|
+
blurActive(); caseOpen.value = false; definitionTechnicalOpen.value = false;
|
|
112
|
+
await nextTick();
|
|
113
|
+
preparationIds.value = [...ids]; invalidInputCases.value = []; rerunOf.value = previous;
|
|
114
|
+
preparingCases.value.forEach(initializeInputs);
|
|
115
|
+
state.error.value = ''; preparationOpen.value = true;
|
|
116
|
+
}
|
|
117
|
+
function closePreparation() { blurActive(); preparationOpen.value = false; }
|
|
118
|
+
async function execute() {
|
|
119
|
+
if (!props.canInvoke || !preparationConfigured.value || !profile.value || busy.value) return;
|
|
120
|
+
await state.run({
|
|
121
|
+
client_request_uuid: crypto.randomUUID(), profile_uuid: profile.value,
|
|
122
|
+
case_uuids: preparationIds.value, inputs: Object.fromEntries(preparationIds.value.map(id => [id, inputValues.value[id]])),
|
|
123
|
+
...(rerunOf.value ? { rerun_of_run_uuid: rerunOf.value } : {}),
|
|
124
|
+
});
|
|
125
|
+
if (!state.error.value) { focusAfterPreparation.value = true; closePreparation(); selected.value = []; tab.value = 'runs'; showResult.value = true; }
|
|
126
|
+
}
|
|
127
|
+
function preparationClosed() { if (focusAfterPreparation.value) { focusAfterPreparation.value = false; resultView.value?.focus(); } }
|
|
128
|
+
async function openRun(uuid: string) {
|
|
129
|
+
await state.open(uuid);
|
|
130
|
+
if (!state.error.value) { tab.value = 'runs'; showResult.value = true; await nextTick(); resultView.value?.focus(); }
|
|
131
|
+
}
|
|
132
|
+
async function rerun() {
|
|
133
|
+
if (!state.detail.value) return;
|
|
134
|
+
const run = state.detail.value.run;
|
|
135
|
+
if (!state.profiles.value.some(p => p.uuid === run.profile_uuid)) { state.error.value = 'REGRESSION_PROFILE_UNAVAILABLE'; return; }
|
|
136
|
+
const ids = state.detail.value.case_executions.filter(c => c.verdict !== 'passed').map(c => c.case_uuid);
|
|
137
|
+
if (!ids.length) return;
|
|
138
|
+
for (const id of ids) {
|
|
139
|
+
const snapshot = run.input_snapshot[id];
|
|
140
|
+
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) { state.error.value = 'REGRESSION_INVALID_REQUEST'; return; }
|
|
141
|
+
inputValues.value[id] = structuredClone(toRaw(snapshot)) as Record<string, unknown>;
|
|
142
|
+
}
|
|
143
|
+
profile.value = run.profile_uuid;
|
|
144
|
+
await nextTick();
|
|
145
|
+
await prepare(ids, run.uuid);
|
|
146
|
+
}
|
|
147
|
+
async function showDefinitionTechnical() { blurActive(); caseOpen.value = false; await nextTick(); definitionTechnicalOpen.value = true; }
|
|
148
|
+
</script>
|
|
149
|
+
|
|
150
|
+
<template>
|
|
151
|
+
<section v-if="canRead" class="mx-auto max-w-7xl space-y-5 p-4 text-default" data-testid="regression-workbench">
|
|
152
|
+
<header class="flex flex-wrap items-start justify-between gap-4">
|
|
153
|
+
<div><h1 class="text-2xl font-semibold text-highlighted">{{ t('regression.title') }}</h1><p class="mt-1 text-sm text-muted">{{ t('regression.description') }}</p></div>
|
|
154
|
+
<UButton color="neutral" variant="outline" icon="i-heroicons-arrow-path" :label="t('regression.actions.refresh')" :loading="state.loading.value" @click="state.load" />
|
|
155
|
+
</header>
|
|
156
|
+
<UAlert v-if="state.error.value && !preparationOpen" color="error" :title="t('regression.errors.' + state.error.value)" data-testid="regression-error" />
|
|
157
|
+
<UTabs v-model="tab" :items="tabItems" :content="false" variant="link" class="w-full" :aria-label="t('regression.title')" />
|
|
158
|
+
|
|
159
|
+
<section v-if="tab === 'cases'" class="space-y-4" data-testid="regression-catalog">
|
|
160
|
+
<div class="flex flex-wrap items-end justify-between gap-3">
|
|
161
|
+
<div><h2 class="text-lg font-semibold text-highlighted" data-testid="regression-case-count">{{ state.loading.value && !selectedProfile ? t('regression.states.loading') : selectedProfile ? t('regression.overview.available', { count: available.length }) : t('regression.overview.unavailable') }}</h2><p v-if="selectedProfile" class="mt-1 text-sm text-muted">{{ t('regression.catalog.environment', { name: t(selectedProfile.title_key) }) }}</p></div>
|
|
162
|
+
<UFormField v-if="state.profiles.value.length > 1" :label="t('regression.fields.profile')"><USelect v-model="profile" :items="profileItems" class="w-64" /></UFormField>
|
|
163
|
+
</div>
|
|
164
|
+
<UAlert v-if="busy" color="info" :title="t('regression.overview.busy')"><template #actions><UButton color="neutral" variant="outline" :label="t('regression.actions.viewRuns')" @click="tab = 'runs'; showResult = false" /></template></UAlert>
|
|
165
|
+
<div v-if="available.length > 1 || filter || suite !== 'all'" class="flex flex-wrap items-end gap-3">
|
|
166
|
+
<UFormField v-if="state.suites.value.length > 1" :label="t('regression.fields.suite')"><USelect v-model="suite" :items="suiteItems" class="w-48" /></UFormField>
|
|
167
|
+
<UFormField :label="t('regression.fields.filter')"><UInput v-model="filter" icon="i-heroicons-magnifying-glass" class="w-64" /></UFormField>
|
|
168
|
+
<span class="pb-2 text-sm text-muted">{{ t('regression.overview.showing', { count: visible.length, total: available.length }) }}</span>
|
|
169
|
+
</div>
|
|
170
|
+
<div v-if="canInvoke && selected.length" class="flex items-center justify-between gap-3 rounded-lg bg-primary/10 p-3" data-testid="regression-batch-actions"><span class="text-sm">{{ t('regression.overview.selected', { count: selected.length }) }}</span><UButton :label="t('regression.actions.runSelected', { count: selected.length })" :disabled="busy || state.loading.value" data-testid="regression-run-selected" @click="prepare(selected)" /></div>
|
|
171
|
+
<div class="overflow-x-auto rounded-xl border border-default">
|
|
172
|
+
<table class="w-full text-left text-sm">
|
|
173
|
+
<thead class="bg-elevated/60 text-muted"><tr><th v-if="canInvoke" class="w-12 p-4"><UCheckbox :model-value="visibleSelection" :disabled="!visible.length" :aria-label="t('regression.actions.selectVisible')" data-testid="regression-select-visible" @update:model-value="selectVisible" /></th><th scope="col" class="p-4 font-medium">{{ t('regression.catalog.case') }}</th><th scope="col" class="p-4 font-medium">{{ t('regression.catalog.purpose') }}</th><th scope="col" class="p-4 font-medium">{{ t('regression.catalog.latest') }}</th><th scope="col" class="p-4 font-medium">{{ t('regression.catalog.actions') }}</th></tr></thead>
|
|
174
|
+
<tbody>
|
|
175
|
+
<tr v-for="item in visible" :key="item.uuid" class="border-t border-default align-top hover:bg-elevated/30" :data-testid="'regression-case-' + item.key">
|
|
176
|
+
<td v-if="canInvoke" class="p-4"><UCheckbox :model-value="selected.includes(item.uuid)" :aria-label="t('regression.actions.selectCase', { name: t(item.title_key) })" @update:model-value="value => { selected = value ? [...selected, item.uuid] : selected.filter(id => id !== item.uuid) }" /></td>
|
|
177
|
+
<td class="min-w-44 p-4 font-medium text-highlighted">{{ t(item.title_key) }}</td>
|
|
178
|
+
<td class="min-w-64 max-w-md p-4 text-muted">{{ t(item.description_key) }}</td>
|
|
179
|
+
<td class="min-w-32 p-4"><UBadge v-if="latestRuns.has(item.uuid)" :color="color(latestRuns.get(item.uuid)!.verdict)" variant="subtle" data-testid="regression-latest-run">{{ status(latestRuns.get(item.uuid)!.phase === 'completed' ? latestRuns.get(item.uuid)!.verdict : latestRuns.get(item.uuid)!.phase) }}</UBadge><span v-else class="text-muted">{{ t('regression.catalog.notRun') }}</span></td>
|
|
180
|
+
<td class="p-4"><div class="flex flex-wrap gap-2"><UButton color="neutral" variant="outline" :label="t('regression.actions.viewCase')" @click="viewCase(item)" /><UButton v-if="canInvoke" :label="t('regression.actions.runCase')" :disabled="busy || state.loading.value || !profile" data-testid="regression-run-case" @click="prepare([item.uuid])" /></div></td>
|
|
181
|
+
</tr>
|
|
182
|
+
<tr v-if="!visible.length"><td :colspan="canInvoke ? 5 : 4" class="p-8 text-center text-muted">{{ state.loading.value ? t('regression.states.loading') : state.error.value ? t('regression.overview.unavailable') : t('regression.states.empty') }}</td></tr>
|
|
183
|
+
</tbody>
|
|
184
|
+
</table>
|
|
185
|
+
</div>
|
|
186
|
+
<p class="text-xs text-muted">{{ t('regression.catalog.historyScope') }}</p>
|
|
187
|
+
</section>
|
|
188
|
+
|
|
189
|
+
<section v-else-if="!showResult || !state.detail.value" class="space-y-4" data-testid="regression-history">
|
|
190
|
+
<div><h2 class="text-lg font-semibold text-highlighted">{{ t('regression.tabs.runs') }}</h2><p class="mt-1 text-sm text-muted">{{ t('regression.history.description') }}</p></div>
|
|
191
|
+
<div v-if="!state.runs.value.length" class="rounded-xl border border-default p-10 text-center"><p class="font-medium text-highlighted">{{ t('regression.states.noRuns') }}</p><p class="mt-2 text-sm text-muted">{{ t('regression.history.emptyHelp') }}</p><UButton class="mt-4" color="neutral" variant="outline" :label="t('regression.actions.backToCases')" @click="tab = 'cases'" /></div>
|
|
192
|
+
<div v-else class="overflow-x-auto rounded-xl border border-default"><table class="w-full text-left text-sm"><thead class="bg-elevated/60 text-muted"><tr><th v-for="key in ['time', 'cases', 'result', 'cleanup', 'actions']" :key="key" scope="col" class="p-4 font-medium">{{ t('regression.history.' + key) }}</th></tr></thead><tbody><tr v-for="run in state.runs.value" :key="run.uuid" class="border-t border-default align-top"><td class="whitespace-nowrap p-4 text-muted">{{ new Date(run.created_at).toLocaleString() }}</td><td class="min-w-52 p-4"><p class="font-medium text-highlighted">{{ run.selection_snapshot.map(definition => t(definition.title_key)).join(' · ') }}</p><p class="mt-1 text-xs text-muted">{{ t(run.profile_snapshot.title_key) }} · {{ t('regression.results.total', { count: run.selection_snapshot.length }) }}</p></td><td class="p-4"><UBadge :color="color(run.verdict)" variant="subtle">{{ status(run.phase === 'completed' ? run.verdict : run.phase) }}</UBadge></td><td class="p-4">{{ status(run.cleanup_state) }}</td><td class="p-4"><UButton color="neutral" variant="outline" :label="t('regression.actions.viewResult')" @click="openRun(run.uuid)" /></td></tr></tbody></table></div>
|
|
193
|
+
</section>
|
|
194
|
+
<PXRegressionRunDetail v-else ref="resultView" :detail="state.detail.value" :describe-case="describeCase" :can-invoke="canInvoke" :can-manage="canManage" :busy="busy || state.loading.value" :disconnected="state.connection.value === 'error'" @back="showResult = false" @cancel="state.cancel" @cleanup="state.cleanup" @rerun="rerun" @report="state.download" @reconnect="state.reconnect" />
|
|
195
|
+
|
|
196
|
+
<USlideover v-model:open="caseOpen" :title="viewedCase ? t(viewedCase.title_key) : t('regression.actions.viewCase')" :description="t('regression.caseDetail.description')" :ui="{ content: 'max-w-xl' }">
|
|
197
|
+
<template #body><div v-if="viewedCase" class="space-y-6" data-testid="regression-case-detail">
|
|
198
|
+
<section><h3 class="font-semibold text-highlighted">{{ t('regression.caseDetail.purpose') }}</h3><p class="mt-2 text-sm text-muted">{{ t(viewedCase.description_key) }}</p></section>
|
|
199
|
+
<section><h3 class="font-semibold text-highlighted">{{ t('regression.caseDetail.steps', { count: viewedCase.steps.length }) }}</h3><ol class="mt-3 space-y-3"><li v-for="(step, index) in viewedCase.steps" :key="step.uuid" class="flex gap-3 text-sm"><span class="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">{{ index + 1 }}</span><span>{{ t(step.title_key) }}</span></li></ol></section>
|
|
200
|
+
<template v-if="describeCase(viewedCase)"><section><h3 class="font-semibold text-highlighted">{{ t('regression.caseDetail.expected') }}</h3><ul class="mt-2 list-disc space-y-2 pl-5 text-sm text-muted"><li v-for="key in describeCase(viewedCase)!.expectationKeys" :key="key">{{ t(key) }}</li></ul></section><UAlert color="info" variant="subtle" :title="t('regression.caseDetail.impact')" :description="t(describeCase(viewedCase)!.impactKey)" /></template>
|
|
201
|
+
<UAlert v-else color="error" :title="t('regression.errors.REGRESSION_PRESENTATION_MISSING')" />
|
|
202
|
+
<UButton color="neutral" variant="link" :label="t('regression.actions.technical')" class="px-0" @click="showDefinitionTechnical" />
|
|
203
|
+
</div></template>
|
|
204
|
+
<template #footer><div class="flex w-full justify-end gap-2"><UButton color="neutral" variant="outline" :label="t('regression.actions.close')" @click="blurActive(); caseOpen = false" /><UButton v-if="canInvoke && viewedCase" :label="t('regression.actions.runCase')" :disabled="busy || state.loading.value" @click="prepare([viewedCase.uuid])" /></div></template>
|
|
205
|
+
</USlideover>
|
|
206
|
+
|
|
207
|
+
<UModal v-model:open="preparationOpen" :title="t('regression.preparation.title', { count: preparingCases.length })" :description="t('regression.preparation.description')" :dismissible="!state.loading.value" :close="!state.loading.value" :ui="{ content: 'max-w-2xl' }" @after:leave="preparationClosed">
|
|
208
|
+
<template #body>
|
|
209
|
+
<div class="space-y-5" data-testid="regression-preparation">
|
|
210
|
+
<p v-if="selectedProfile" class="text-sm font-medium text-highlighted">{{ t('regression.catalog.environment', { name: t(selectedProfile.title_key) }) }}</p>
|
|
211
|
+
<p v-if="selectedProfile" class="text-sm text-muted">{{ t('regression.profileDescriptions.' + selectedProfile.kind) }}</p>
|
|
212
|
+
<UAlert v-if="state.error.value" color="error" :title="t('regression.errors.' + state.error.value)" data-testid="regression-error" />
|
|
213
|
+
<UAlert v-if="!preparationConfigured" color="error" :title="t('regression.errors.REGRESSION_PRESENTATION_MISSING')" />
|
|
214
|
+
<UForm v-if="preparationConfigured" id="regression-run-form" :state="inputValues" :schema="formSchema" class="space-y-6" @submit="execute" @error="preparationError">
|
|
215
|
+
<section v-for="definition in preparingCases" :key="definition.uuid" class="space-y-4">
|
|
216
|
+
<div><h3 class="font-semibold text-highlighted">{{ t(definition.title_key) }}</h3><p class="mt-1 text-sm text-muted">{{ t(definition.description_key) }}</p></div>
|
|
217
|
+
<UAlert color="info" variant="subtle" :title="t('regression.caseDetail.impact')" :description="t(describeCase(definition)!.impactKey)" />
|
|
218
|
+
<details :open="preparingCases.length === 1 || invalidInputCases.includes(definition.uuid)" class="rounded-lg border border-default p-3" :data-testid="'regression-inputs-' + definition.key">
|
|
219
|
+
<summary class="cursor-pointer text-sm font-medium text-highlighted">{{ t('regression.preparation.reviewInputs') }}</summary>
|
|
220
|
+
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
|
221
|
+
<UFormField v-for="field in describeCase(definition)!.fields" :key="field.key" :class="field.control === 'textarea' ? 'sm:col-span-2' : ''" :name="definition.uuid + '.' + field.key" :label="t(field.labelKey)" :help="field.helpKey ? t(field.helpKey) : undefined" :required="(definition.input_schema.required as string[] | undefined)?.includes(field.key)">
|
|
222
|
+
<UTextarea v-if="field.control === 'textarea'" v-model="inputValues[definition.uuid][field.key]" :rows="3" class="w-full" :disabled="state.loading.value" />
|
|
223
|
+
<UInput v-else v-model="inputValues[definition.uuid][field.key]" class="w-full" :disabled="state.loading.value" />
|
|
224
|
+
</UFormField>
|
|
225
|
+
</div>
|
|
226
|
+
</details>
|
|
227
|
+
</section>
|
|
228
|
+
</UForm>
|
|
229
|
+
</div>
|
|
230
|
+
</template>
|
|
231
|
+
<template #footer><div class="flex w-full justify-end gap-2"><UButton color="neutral" variant="outline" :disabled="state.loading.value" :label="t('regression.actions.cancelPreparation')" @click="closePreparation" /><UButton v-if="canInvoke" type="submit" form="regression-run-form" :label="t('regression.actions.start')" :disabled="!preparationConfigured || busy || !profile" :loading="state.loading.value" data-testid="regression-run" /></div></template>
|
|
232
|
+
</UModal>
|
|
233
|
+
<UModal v-model:open="definitionTechnicalOpen" :title="t('regression.actions.technical')" :description="t('regression.technical.definition')" :ui="{ content: 'max-w-3xl' }"><template #body><pre class="overflow-auto rounded-lg bg-elevated p-3 text-xs">{{ JSON.stringify({ definition: viewedCase, profile: selectedProfile }, null, 2) }}</pre></template><template #footer><UButton color="neutral" variant="outline" :label="t('regression.actions.close')" @click="blurActive(); definitionTechnicalOpen = false" /></template></UModal>
|
|
234
|
+
</section>
|
|
235
|
+
</template>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { AssertionResult } from '@artisan-cloud/plugin-framework-client';
|
|
2
|
+
import type { RegressionResultField, RegressionResultPresentation } from '../types/regression';
|
|
3
|
+
|
|
4
|
+
export interface RegressionComparisonRow {
|
|
5
|
+
label: string;
|
|
6
|
+
expected: string;
|
|
7
|
+
actual: string;
|
|
8
|
+
matches: boolean;
|
|
9
|
+
recorded: boolean;
|
|
10
|
+
}
|
|
11
|
+
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
|
12
|
+
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
13
|
+
function equal(a: unknown, b: unknown): boolean {
|
|
14
|
+
if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => equal(v, b[i]));
|
|
15
|
+
if (object(a) && object(b)) return Object.keys(a).length === Object.keys(b).length && Object.keys(a).every(key => Object.hasOwn(b, key) && equal(a[key], b[key]));
|
|
16
|
+
return a === b;
|
|
17
|
+
}
|
|
18
|
+
function format(value: unknown, field: RegressionResultField, t: Translate): string | null {
|
|
19
|
+
if (field.format === 'text') return typeof value === 'string' ? value : null;
|
|
20
|
+
if (field.format === 'number') return typeof value === 'number' && Number.isFinite(value) ? String(value) : null;
|
|
21
|
+
if (field.format === 'boolean') return typeof value === 'boolean' ? t(value ? 'regression.resultValues.yes' : 'regression.resultValues.no') : null;
|
|
22
|
+
if (field.format === 'enum') return typeof value === 'string' && field.values?.[value] ? t(field.values[value]) : null;
|
|
23
|
+
if (field.format === 'numbers') return Array.isArray(value) && value.every(item => typeof item === 'number') ? value.join(' / ') : null;
|
|
24
|
+
if (field.format === 'references') return Array.isArray(value) && value.every(item => typeof item === 'string') ? t('regression.resultValues.referencesCount', { count: value.length }) : null;
|
|
25
|
+
if (field.format === 'items' && Array.isArray(value) && field.fields?.length) {
|
|
26
|
+
const entries: string[] = [];
|
|
27
|
+
for (const item of value) {
|
|
28
|
+
if (!object(item)) return null;
|
|
29
|
+
const parts: string[] = [];
|
|
30
|
+
for (const child of field.fields) {
|
|
31
|
+
const rendered = format(item[child.key], child, t);
|
|
32
|
+
if (rendered === null) return null;
|
|
33
|
+
parts.push(t('regression.resultValues.fieldValue', { field: t(child.labelKey), value: rendered }));
|
|
34
|
+
}
|
|
35
|
+
entries.push(parts.join(t('regression.resultValues.separator')));
|
|
36
|
+
}
|
|
37
|
+
return entries.length ? entries.join('\n') : t('regression.resultValues.none');
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
export function regressionComparison(assertion: AssertionResult, presentation: RegressionResultPresentation | undefined, t: Translate): RegressionComparisonRow[] | null {
|
|
42
|
+
if (!presentation || !presentation.fields.length) return null;
|
|
43
|
+
const rows: RegressionComparisonRow[] = [];
|
|
44
|
+
const add = (field: RegressionResultField, expected: unknown, actual: unknown, prefix = '') => {
|
|
45
|
+
const isReference = field.format === 'reference';
|
|
46
|
+
const knownExpected = isReference ? typeof expected === 'string' : format(expected, field, t) !== null;
|
|
47
|
+
const knownActual = isReference ? typeof actual === 'string' : format(actual, field, t) !== null;
|
|
48
|
+
rows.push({
|
|
49
|
+
label: prefix ? t('regression.resultValues.groupField', { group: prefix, field: t(field.labelKey) }) : t(field.labelKey),
|
|
50
|
+
expected: isReference && knownExpected ? t('regression.resultValues.selectedReference') : format(expected, field, t) ?? t('regression.states.unrecorded'),
|
|
51
|
+
actual: isReference && knownActual ? t(expected === actual ? 'regression.resultValues.selectedReference' : 'regression.resultValues.otherReference') : format(actual, field, t) ?? t('regression.states.unrecorded'),
|
|
52
|
+
matches: knownExpected && knownActual && equal(expected, actual), recorded: knownExpected && knownActual,
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
if (presentation.shape === 'scalar') add(presentation.fields[0], assertion.expected, assertion.actual);
|
|
56
|
+
else if (presentation.shape === 'record') {
|
|
57
|
+
if (!object(assertion.expected) || !object(assertion.actual)) return null;
|
|
58
|
+
for (const field of presentation.fields) add(field, assertion.expected[field.key], assertion.actual[field.key]);
|
|
59
|
+
} else {
|
|
60
|
+
if (!Array.isArray(assertion.expected) || !Array.isArray(assertion.actual) || !presentation.itemLabelKey) return null;
|
|
61
|
+
const size = Math.max(assertion.expected.length, assertion.actual.length);
|
|
62
|
+
for (let index = 0; index < size; index++) {
|
|
63
|
+
const prefix = t(presentation.itemLabelKey, { index: index + 1 });
|
|
64
|
+
if (presentation.shape === 'references') add(presentation.fields[0], assertion.expected[index], assertion.actual[index], prefix);
|
|
65
|
+
else {
|
|
66
|
+
const expected = assertion.expected[index], actual = assertion.actual[index];
|
|
67
|
+
if (expected !== undefined && !object(expected) || actual !== undefined && !object(actual)) return null;
|
|
68
|
+
for (const field of presentation.fields) add(field, expected?.[field.key], actual?.[field.key], prefix);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return rows;
|
|
73
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { ref, onScopeDispose } from 'vue';
|
|
2
|
+
import type { RegressionClient, CaseDefinition, ProfileDefinition, Run, RunDetail, CreateRun, SuiteDefinition } from '@artisan-cloud/plugin-framework-client';
|
|
3
|
+
|
|
4
|
+
export function usePowerXRegression(client: RegressionClient) {
|
|
5
|
+
const suites = ref<SuiteDefinition[]>([]);
|
|
6
|
+
const cases = ref<CaseDefinition[]>([]), profiles = ref<ProfileDefinition[]>([]), runs = ref<Run[]>([]);
|
|
7
|
+
const detail = ref<RunDetail | null>(null), error = ref(''), loading = ref(false);
|
|
8
|
+
const connection = ref<'idle' | 'connected' | 'error'>('idle');
|
|
9
|
+
let stream: AbortController | null = null, generation = 0;
|
|
10
|
+
const fail = (e: any, operation: 'catalog' | 'request' = 'request') => {
|
|
11
|
+
const code = e?.data?.error?.code || e?.message;
|
|
12
|
+
const status = e?.response?.status ?? e?.statusCode ?? e?.status;
|
|
13
|
+
if (/^REGRESSION_[A-Z_]+$/.test(code || '')) error.value = code;
|
|
14
|
+
else if (operation === 'catalog' && status === 404) error.value = 'REGRESSION_API_UNAVAILABLE';
|
|
15
|
+
else error.value = 'REGRESSION_REQUEST_FAILED';
|
|
16
|
+
};
|
|
17
|
+
function stop() { stream?.abort(); stream = null; connection.value = 'idle'; }
|
|
18
|
+
function reset() { generation++; stop(); suites.value = []; cases.value = []; profiles.value = []; runs.value = []; detail.value = null; error.value = ''; loading.value = false; }
|
|
19
|
+
async function load() {
|
|
20
|
+
const revision = generation; loading.value = true; error.value = '';
|
|
21
|
+
try {
|
|
22
|
+
const [c, p, r, s] = await Promise.all([client.cases(), client.profiles(), client.runs(), client.suites()]);
|
|
23
|
+
if (revision === generation) { suites.value = s.items; cases.value = c.items; profiles.value = p.items; runs.value = r.items; }
|
|
24
|
+
} catch (e) { if (revision === generation) fail(e, 'catalog'); }
|
|
25
|
+
finally { if (revision === generation) loading.value = false; }
|
|
26
|
+
}
|
|
27
|
+
async function watch(snapshot: RunDetail) {
|
|
28
|
+
stop(); detail.value = snapshot;
|
|
29
|
+
const controller = new AbortController(); stream = controller; connection.value = 'connected';
|
|
30
|
+
try {
|
|
31
|
+
await client.watch(snapshot, controller.signal, next => { if (stream === controller) { detail.value = next; runs.value = runs.value.map(r => r.uuid === next.run.uuid ? next.run : r); } });
|
|
32
|
+
if (stream === controller) connection.value = 'idle';
|
|
33
|
+
} catch (e) { if (stream === controller && !controller.signal.aborted) { connection.value = 'error'; fail(e); } }
|
|
34
|
+
}
|
|
35
|
+
async function open(id: string) {
|
|
36
|
+
const revision = generation; error.value = '';
|
|
37
|
+
try { const d = await client.get(id); if (revision === generation) void watch(d); }
|
|
38
|
+
catch (e) { if (revision === generation) fail(e); }
|
|
39
|
+
}
|
|
40
|
+
async function run(body: CreateRun) {
|
|
41
|
+
const revision = generation; error.value = ''; loading.value = true;
|
|
42
|
+
try { const d = await client.create(body); if (revision === generation) { void watch(d); runs.value = [d.run, ...runs.value.filter(r => r.uuid !== d.run.uuid)]; } }
|
|
43
|
+
catch (e) { if (revision === generation) fail(e); }
|
|
44
|
+
finally { if (revision === generation) loading.value = false; }
|
|
45
|
+
}
|
|
46
|
+
async function cancel() { if (!detail.value) return; const revision = generation; try { const d = await client.cancel(detail.value.run.uuid); if (revision === generation && (!detail.value || d.run.revision >= detail.value.run.revision)) detail.value = d; } catch (e) { if (revision === generation) fail(e); } }
|
|
47
|
+
async function cleanup() { if (!detail.value) return; const revision = generation; try { const d = await client.cleanup(detail.value.run.uuid); if (revision === generation) void watch(d); } catch (e) { if (revision === generation) fail(e); } }
|
|
48
|
+
async function reconnect() { if (detail.value) await open(detail.value.run.uuid); }
|
|
49
|
+
async function download() {
|
|
50
|
+
if (!detail.value) return; const revision = generation;
|
|
51
|
+
try { const report = await client.report(detail.value.run.uuid); if (revision !== generation) return; const url = URL.createObjectURL(new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' })); const a = document.createElement('a'); a.href = url; a.download = 'regression-report.json'; a.click(); URL.revokeObjectURL(url); }
|
|
52
|
+
catch (e) { if (revision === generation) fail(e); }
|
|
53
|
+
}
|
|
54
|
+
onScopeDispose(reset);
|
|
55
|
+
return { suites, cases, profiles, runs, detail, error, loading, connection, load, open, run, cancel, cleanup, reconnect, download, stop, reset };
|
|
56
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Plugin-owned business explanations and form fields for a registered scenario. */
|
|
2
|
+
export interface RegressionCasePresentation {
|
|
3
|
+
expectationKeys: string[];
|
|
4
|
+
impactKey: string;
|
|
5
|
+
fields: Array<{
|
|
6
|
+
key: string;
|
|
7
|
+
labelKey: string;
|
|
8
|
+
control: 'text' | 'textarea';
|
|
9
|
+
helpKey?: string;
|
|
10
|
+
}>;
|
|
11
|
+
results?: Record<string, RegressionResultPresentation>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Explicit plugin-owned mapping; the renderer never guesses business fields. */
|
|
15
|
+
export interface RegressionResultField {
|
|
16
|
+
key: string;
|
|
17
|
+
labelKey: string;
|
|
18
|
+
format: 'text' | 'number' | 'boolean' | 'enum' | 'numbers' | 'items' | 'reference' | 'references';
|
|
19
|
+
values?: Record<string, string>;
|
|
20
|
+
fields?: RegressionResultField[];
|
|
21
|
+
}
|
|
22
|
+
export interface RegressionResultPresentation {
|
|
23
|
+
shape: 'scalar' | 'record' | 'list' | 'references';
|
|
24
|
+
fields: RegressionResultField[];
|
|
25
|
+
itemLabelKey?: string;
|
|
26
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@source "../../app";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@artisan-cloud/plugin-framework-admin",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./index.ts",
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
],
|
|
15
15
|
"peerDependencies": {
|
|
16
16
|
"nuxt": "^4.2.0",
|
|
17
|
-
"@artisan-cloud/plugin-framework-client": "
|
|
17
|
+
"@artisan-cloud/plugin-framework-client": "0.0.14",
|
|
18
|
+
"valibot": "^1.2.0",
|
|
19
|
+
"@types/node": "^22.18.0"
|
|
18
20
|
},
|
|
19
21
|
"devDependencies": {
|
|
20
22
|
"nuxt": "^4.2.0"
|