449 lines
12 KiB
Vue
449 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue';
|
|
import { useRouter } from 'vue-router';
|
|
import { ElMessage } from 'element-plus';
|
|
import { useAdminLocale } from '../composables/useAdminLocale';
|
|
import { useSmokeTestsAllowed } from '../composables/useSmokeTestsAllowed';
|
|
import api from '../api';
|
|
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
|
|
|
const { t, locale, localeTag } = useAdminLocale();
|
|
const router = useRouter();
|
|
const { allowed: smokeTestsAllowed, ensureLoaded: ensureSmokeTestsAllowed } = useSmokeTestsAllowed();
|
|
|
|
type SuiteInfo = { id: string; name: string; description: string; caseCount: number };
|
|
type CaseMeta = { id: string; suite: string; name: string; uatRef?: string };
|
|
type CaseResult = CaseMeta & {
|
|
status: 'PASS' | 'FAIL' | 'SKIP';
|
|
durationMs: number;
|
|
stepCount?: number;
|
|
message?: string;
|
|
error?: string;
|
|
details?: string[];
|
|
};
|
|
type RunSummary = {
|
|
runId: string;
|
|
startedAt: string;
|
|
finishedAt: string;
|
|
durationMs: number;
|
|
total: number;
|
|
passed: number;
|
|
failed: number;
|
|
skipped: number;
|
|
suites: string[];
|
|
results: CaseResult[];
|
|
};
|
|
|
|
const suites = ref<SuiteInfo[]>([]);
|
|
const selectedSuites = ref<string[]>([]);
|
|
const running = ref(false);
|
|
const lastRun = ref<RunSummary | null>(null);
|
|
const filterStatus = ref('');
|
|
|
|
const suiteName = (id: string) => suites.value.find((s) => s.id === id)?.name ?? id;
|
|
|
|
const filteredResults = computed(() => {
|
|
const rows = lastRun.value?.results ?? [];
|
|
if (!filterStatus.value) return rows;
|
|
return rows.filter((r) => r.status === filterStatus.value);
|
|
});
|
|
|
|
const passRate = computed(() => {
|
|
if (!lastRun.value?.total) return 0;
|
|
return Math.round((lastRun.value.passed / lastRun.value.total) * 100);
|
|
});
|
|
|
|
const runLogText = computed(() => (lastRun.value ? formatRunLog(lastRun.value) : ''));
|
|
|
|
function formatCaseHeader(row: CaseResult) {
|
|
const parts = [
|
|
`[${row.status}]`,
|
|
row.id,
|
|
suiteName(row.suite),
|
|
row.name,
|
|
row.uatRef ? `UAT:${row.uatRef}` : null,
|
|
row.stepCount ? `${row.stepCount} steps` : null,
|
|
`${row.durationMs}ms`,
|
|
row.error || row.message || '',
|
|
].filter(Boolean);
|
|
return parts.join(' | ');
|
|
}
|
|
|
|
function formatCaseBlock(row: CaseResult) {
|
|
const lines = [formatCaseHeader(row)];
|
|
if (row.details?.length) {
|
|
lines.push(...row.details.map((d) => ` ${d.replace(/\n/g, '\n ')}`));
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function formatCaseLine(row: CaseResult) {
|
|
return formatCaseBlock(row);
|
|
}
|
|
|
|
function formatRunLog(run: RunSummary) {
|
|
const lines = [
|
|
'=== Smoke Test Report ===',
|
|
`Run ID: ${run.runId}`,
|
|
`Started: ${run.startedAt}`,
|
|
`Finished: ${run.finishedAt}`,
|
|
`Duration: ${run.durationMs}ms`,
|
|
`Summary: pass=${run.passed}, fail=${run.failed}, skip=${run.skipped}, total=${run.total}`,
|
|
`Suites: ${run.suites.join(', ')}`,
|
|
'',
|
|
'--- Cases ---',
|
|
...run.results.map((row) => formatCaseBlock(row)),
|
|
];
|
|
return lines.join('\n\n');
|
|
}
|
|
|
|
async function copyText(text: string) {
|
|
if (!text) return;
|
|
try {
|
|
if (navigator.clipboard?.writeText) {
|
|
await navigator.clipboard.writeText(text);
|
|
} else {
|
|
const ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
ta.style.position = 'fixed';
|
|
ta.style.left = '-9999px';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
}
|
|
ElMessage.success(t('smoke.msg.copy_ok'));
|
|
} catch {
|
|
ElMessage.error(t('smoke.msg.copy_failed'));
|
|
}
|
|
}
|
|
|
|
async function loadMeta() {
|
|
const { data } = await api.get('/admin/smoke-tests/suites');
|
|
suites.value = data.data?.suites ?? [];
|
|
if (data.data?.lastRun) lastRun.value = data.data.lastRun;
|
|
if (!selectedSuites.value.length) {
|
|
selectedSuites.value = suites.value.map((s) => s.id);
|
|
}
|
|
}
|
|
|
|
async function runTests() {
|
|
running.value = true;
|
|
try {
|
|
const { data } = await api.post('/admin/smoke-tests/run', {
|
|
suites: selectedSuites.value.length ? selectedSuites.value : undefined,
|
|
});
|
|
lastRun.value = data.data as RunSummary;
|
|
if (lastRun.value.failed > 0) {
|
|
ElMessage.warning(t('smoke.msg.has_failures', { n: lastRun.value.failed }));
|
|
} else {
|
|
ElMessage.success(t('smoke.msg.all_passed', { n: lastRun.value.passed }));
|
|
}
|
|
} catch (e: unknown) {
|
|
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
|
ElMessage.error(msg || t('smoke.msg.run_failed'));
|
|
} finally {
|
|
running.value = false;
|
|
}
|
|
}
|
|
|
|
function formatTime(v: string) {
|
|
return new Date(v).toLocaleString(localeTag.value);
|
|
}
|
|
|
|
function statusTagType(status: string) {
|
|
if (status === 'PASS') return 'success';
|
|
if (status === 'FAIL') return 'danger';
|
|
return 'info';
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await ensureSmokeTestsAllowed();
|
|
if (!smokeTestsAllowed.value) {
|
|
router.replace('/');
|
|
return;
|
|
}
|
|
await loadMeta();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="admin-list-page smoke-page">
|
|
<el-card class="intro-card" shadow="never">
|
|
<p class="intro-text">{{ t('smoke.intro') }}</p>
|
|
<ul class="intro-list">
|
|
<li>{{ t('smoke.intro_rule') }}</li>
|
|
<li>{{ t('smoke.intro_db') }}</li>
|
|
<li>{{ t('smoke.intro_bet_flow') }}</li>
|
|
<li>{{ t('smoke.intro_note') }}</li>
|
|
</ul>
|
|
</el-card>
|
|
|
|
<el-card class="filter-card" shadow="never">
|
|
<el-form inline>
|
|
<el-form-item :label="t('smoke.field.suites')">
|
|
<el-select
|
|
v-model="selectedSuites"
|
|
multiple
|
|
collapse-tags
|
|
collapse-tags-tooltip
|
|
:placeholder="t('smoke.ph.suites')"
|
|
style="min-width: 320px"
|
|
>
|
|
<el-option
|
|
v-for="s in suites"
|
|
:key="s.id"
|
|
:label="`${s.name} (${s.caseCount})`"
|
|
:value="s.id"
|
|
/>
|
|
</el-select>
|
|
</el-form-item>
|
|
<el-form-item>
|
|
<el-button type="primary" :loading="running" @click="runTests">
|
|
{{ t('smoke.btn.run') }}
|
|
</el-button>
|
|
</el-form-item>
|
|
</el-form>
|
|
</el-card>
|
|
|
|
<el-card v-if="lastRun" class="data-card summary-card" shadow="never">
|
|
<div class="summary-head">
|
|
<div>
|
|
<div class="summary-title">{{ t('smoke.last_run') }}</div>
|
|
<div class="summary-meta">
|
|
{{ formatTime(lastRun.startedAt) }} · {{ lastRun.durationMs }}ms · {{ lastRun.runId }}
|
|
</div>
|
|
</div>
|
|
<div class="summary-actions">
|
|
<div class="summary-stats">
|
|
<el-tag type="success">{{ t('smoke.stat.pass') }} {{ lastRun.passed }}</el-tag>
|
|
<el-tag type="danger">{{ t('smoke.stat.fail') }} {{ lastRun.failed }}</el-tag>
|
|
<el-tag>{{ t('smoke.stat.total') }} {{ lastRun.total }}</el-tag>
|
|
<el-tag type="warning">{{ passRate }}%</el-tag>
|
|
</div>
|
|
<el-button size="small" @click="copyText(runLogText)">{{ t('smoke.btn.copy_all') }}</el-button>
|
|
</div>
|
|
</div>
|
|
</el-card>
|
|
|
|
<el-card v-if="lastRun" class="data-card" shadow="never">
|
|
<div class="table-head">
|
|
<span class="table-title">{{ t('smoke.results_title') }}</span>
|
|
<el-select v-model="filterStatus" clearable :placeholder="t('common.all')" style="width: 120px">
|
|
<el-option :label="t('smoke.status.PASS')" value="PASS" />
|
|
<el-option :label="t('smoke.status.FAIL')" value="FAIL" />
|
|
</el-select>
|
|
</div>
|
|
<div class="table-wrap">
|
|
<el-table :data="filteredResults" stripe row-key="id">
|
|
<template #empty>
|
|
<AdminTableEmpty />
|
|
</template>
|
|
<el-table-column type="expand" width="44">
|
|
<template #default="{ row }">
|
|
<div v-if="row.details?.length" class="case-details">
|
|
<pre>{{ row.details.join('\n\n') }}</pre>
|
|
</div>
|
|
<span v-else class="case-details-empty">{{ t('smoke.no_steps') }}</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column type="index" :label="t('common.seq')" width="55" align="center" />
|
|
<el-table-column prop="id" :label="t('smoke.col.id')" width="88" />
|
|
<el-table-column :label="t('smoke.col.suite')" width="120">
|
|
<template #default="{ row }">{{ suiteName(row.suite) }}</template>
|
|
</el-table-column>
|
|
<el-table-column prop="name" :label="t('smoke.col.name')" min-width="180" />
|
|
<el-table-column prop="uatRef" :label="t('smoke.col.uat')" width="88">
|
|
<template #default="{ row }">{{ row.uatRef ?? '—' }}</template>
|
|
</el-table-column>
|
|
<el-table-column :label="t('common.status')" width="96">
|
|
<template #default="{ row }">
|
|
<el-tag :type="statusTagType(row.status)" size="small">
|
|
{{ t(`smoke.status.${row.status}`) }}
|
|
</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column :label="t('smoke.col.steps')" width="72" align="center">
|
|
<template #default="{ row }">{{ row.stepCount ?? '—' }}</template>
|
|
</el-table-column>
|
|
<el-table-column :label="t('smoke.col.duration')" width="96" align="right">
|
|
<template #default="{ row }">{{ row.durationMs }}ms</template>
|
|
</el-table-column>
|
|
<el-table-column :label="t('smoke.col.message')" min-width="200" show-overflow-tooltip>
|
|
<template #default="{ row }">
|
|
<span :class="{ 'fail-msg': row.status === 'FAIL' }">{{ row.error || row.message || '—' }}</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column :label="t('common.actions')" width="88" fixed="right">
|
|
<template #default="{ row }">
|
|
<el-button link type="primary" size="small" @click="copyText(formatCaseBlock(row))">
|
|
{{ t('smoke.btn.copy_one') }}
|
|
</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
</div>
|
|
<div class="log-panel">
|
|
<div class="log-head">
|
|
<span class="table-title">{{ t('smoke.log_title') }}</span>
|
|
<el-button size="small" @click="copyText(runLogText)">{{ t('smoke.btn.copy_all') }}</el-button>
|
|
</div>
|
|
<el-input
|
|
type="textarea"
|
|
class="log-textarea"
|
|
:model-value="runLogText"
|
|
readonly
|
|
:rows="18"
|
|
resize="vertical"
|
|
/>
|
|
</div>
|
|
</el-card>
|
|
|
|
<el-card v-else class="data-card empty-card" shadow="never">
|
|
<p>{{ t('smoke.empty') }}</p>
|
|
</el-card>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.intro-text {
|
|
margin: 0 0 8px;
|
|
color: #666;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.intro-list {
|
|
margin: 0;
|
|
padding-left: 18px;
|
|
color: #888;
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.summary-head {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.summary-title {
|
|
font-size: 15px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.summary-meta {
|
|
font-size: 12px;
|
|
color: #888;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.summary-actions {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-end;
|
|
gap: 8px;
|
|
}
|
|
|
|
.summary-stats {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.log-panel {
|
|
margin-top: 16px;
|
|
border-top: 1px solid var(--el-border-color-lighter);
|
|
padding-top: 12px;
|
|
}
|
|
|
|
.log-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.log-textarea :deep(textarea) {
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
font-size: 12px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.table-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.table-title {
|
|
font-weight: 700;
|
|
}
|
|
|
|
.fail-msg {
|
|
color: var(--el-color-danger);
|
|
}
|
|
|
|
.case-details {
|
|
padding: 8px 12px 12px 48px;
|
|
}
|
|
|
|
.case-details pre {
|
|
margin: 0;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
font-size: 12px;
|
|
line-height: 1.55;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
color: #444;
|
|
}
|
|
|
|
.case-details-empty {
|
|
padding: 8px 12px 12px 48px;
|
|
color: #999;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.empty-card {
|
|
text-align: center;
|
|
color: #888;
|
|
padding: 24px 0;
|
|
}
|
|
|
|
.intro-text,
|
|
.intro-list,
|
|
.summary-meta,
|
|
.empty-card,
|
|
.case-details-empty {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.summary-title,
|
|
.table-title {
|
|
color: var(--text);
|
|
}
|
|
|
|
.case-details {
|
|
border: 1px solid var(--border-soft);
|
|
border-radius: 8px;
|
|
background: #fbfaf7;
|
|
}
|
|
|
|
.case-details pre {
|
|
color: var(--text);
|
|
}
|
|
|
|
@media (max-width: 760px) {
|
|
.summary-actions,
|
|
.summary-stats,
|
|
.table-head,
|
|
.log-head {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
}
|
|
}
|
|
</style>
|