Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 160 additions & 29 deletions application/app/pages/test-run-cases/[id].vue
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,6 @@ const failureCluster = computed(() => {
} | null;
});

const wastedTimeMs = computed(() => testCase.value?.wastedTimeMs ?? 0);

// ── Tabs ────────────────────────────────────────────────────────────────────
// The tab set depends on whether this execution has an error: a failing case
// leads with Diagnosis; a passing one with its Steps and Artifacts.
Expand Down Expand Up @@ -180,15 +178,105 @@ const stepCategoryColor: Record<string, 'info' | 'success' | 'warning' | 'neutra
navigation: 'info',
assertion: 'success',
action: 'warning',
input: 'warning',
api: 'info',
wait: 'neutral',
hook: 'neutral',
fixture: 'neutral',
};

// Widths are set via per-column `meta.class` (Nuxt UI applies these to th/td);
// with `table-fixed w-full` the width-less Step column absorbs the remaining space.
const stepColumns: TableColumn<PerformanceStep>[] = [
{ id: 'status', header: '', size: 32 },
{ accessorKey: 'category', header: 'Category' },
{ accessorKey: 'title', header: 'Step' },
{ accessorKey: 'duration', header: 'Duration' },
{ id: 'index', header: '#', meta: { class: { th: 'w-12', td: 'w-12' } } },
{ id: 'status', header: '', meta: { class: { th: 'w-10', td: 'w-10' } } },
{ accessorKey: 'category', header: 'Category', meta: { class: { th: 'w-28', td: 'w-28' } } },
{ accessorKey: 'title', header: 'Step' }, // no width → absorbs remaining width
{ accessorKey: 'duration', header: 'Duration', meta: { class: { th: 'w-44', td: 'w-44' } } },
];

// ── Steps tab derived data ───────────────────────────────────────────────────
// Per-category rollup for the summary strip above the table. Durations are summed
// over the flat step list (parents include their children), matching how the
// reporter's StepMetrics already reports navigation/wait totals.
const stepSummary = computed(() => {
const byCat = new Map<string, { count: number; duration: number }>();
for (const s of steps.value) {
const entry = byCat.get(s.category) ?? { count: 0, duration: 0 };
entry.count += 1;
entry.duration += s.duration || 0;
byCat.set(s.category, entry);
}
return Array.from(byCat, ([category, v]) => ({ category, ...v })).sort((a, b) => b.duration - a.duration);
});

// Row index of the single slowest step, used to tag that row. Mirrors the header's
// slowestStep (max flat-step duration) but resolved to a stable row.
const slowestStepIndex = computed(() => {
let idx = -1;
let max = -1;
steps.value.forEach((s, i) => {
if ((s.duration || 0) > max) {
max = s.duration || 0;
idx = i;
}
});
return idx;
});

const maxStepDuration = computed(() => steps.value.reduce((m, s) => Math.max(m, s.duration || 0), 0));

// A true waterfall needs a startTime on every step (only runs from a recent
// reporter carry one); otherwise the bars fall back to left-aligned magnitude.
const hasStepTimings = computed(
() => steps.value.length > 0 && steps.value.every((s) => typeof s.startTime === 'number'),
);
const timelineStart = computed(
() =>
testCase.value?.startedAt ??
(hasStepTimings.value ? Math.min(...steps.value.map((s) => s.startTime as number)) : 0),
);
const timelineDuration = computed(() => {
const total = testCase.value?.duration ?? 0;
if (total > 0) return total;
if (hasStepTimings.value) {
const end = Math.max(...steps.value.map((s) => (s.startTime as number) + (s.duration || 0)));
return Math.max(1, end - timelineStart.value);
}
return 0;
});

/** Bar geometry for a step: a real waterfall when timings exist, else magnitude. */
function stepBarStyle(step: PerformanceStep): Record<string, string> {
if (hasStepTimings.value && timelineDuration.value > 0) {
const left = Math.max(
0,
Math.min(100, (((step.startTime as number) - timelineStart.value) / timelineDuration.value) * 100),
);
const width = Math.min(100 - left, Math.max(1.5, ((step.duration || 0) / timelineDuration.value) * 100));
return { left: `${left}%`, width: `${width}%` };
}
const width = maxStepDuration.value > 0 ? Math.max(2, ((step.duration || 0) / maxStepDuration.value) * 100) : 0;
return { left: '0%', width: `${width}%` };
}

/** Step duration as a share of the whole test's wall-clock (e.g. "12%"). */
function stepPctOfTest(duration: number): string {
const total = testCase.value?.duration ?? 0;
if (total <= 0) return '';
const pct = (duration / total) * 100;
if (pct > 0 && pct < 1) return '<1%';
return `${Math.round(pct)}%`;
}

/** Severity color for a duration value, shared by the number and its bar. */
function stepDurationTextClass(duration: number): string {
return duration > 2000 ? 'text-red-600 font-medium' : duration > 500 ? 'text-orange-500' : 'text-gray-500';
}
function stepBarColorClass(duration: number): string {
return duration > 2000 ? 'bg-red-500' : duration > 500 ? 'bg-orange-400' : 'bg-gray-400 dark:bg-gray-500';
}

const environment = computed(() => testCase.value?.testRun?.environment);

// ── Retry command ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -567,63 +655,106 @@ provide(clusterSectionLocatorKey, {
<!-- ── Steps ────────────────────────────────────────────────────── -->
<template #tab-steps>
<div class="space-y-3">
<div v-if="wastedTimeMs > 0" class="flex items-center gap-1.5">
<UBadge color="warning" variant="subtle" size="sm" class="inline-flex items-center gap-1">
<UIcon name="i-lucide-hourglass" class="size-3 shrink-0" />
{{ formatDuration(wastedTimeMs) }} wasted in fixed waits
</UBadge>
<HelpHint topic="case.wasted-time" />
</div>

<div v-if="steps.length > 0">
<TableScroller min-width="34rem" :bleed="false">
<!-- Per-category summary strip -->
<div class="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs mb-3">
<span class="font-medium text-gray-600 dark:text-gray-300">{{ steps.length }} steps</span>
<span class="text-gray-300 dark:text-gray-600">·</span>
<span v-for="c in stepSummary" :key="c.category" class="inline-flex items-center gap-1">
<UBadge :color="stepCategoryColor[c.category] || 'neutral'" variant="soft" size="xs">
{{ c.category }}
</UBadge>
<span class="tabular-nums text-gray-500 dark:text-gray-400"
>×{{ c.count }} · {{ formatDuration(c.duration) }}</span
>
</span>
</div>

<TableScroller min-width="40rem" :bleed="false">
<UTable
:data="steps"
:columns="stepColumns"
:ui="{
base: 'table-fixed border-separate border-spacing-0 min-w-[34rem]',
base: 'table-fixed w-full border-separate border-spacing-0 min-w-[40rem]',
thead: '[&>tr]:bg-elevated/50 [&>tr]:after:content-none',
tbody: '[&>tr]:last:[&>td]:border-b-0',
th: 'first:rounded-l-lg last:rounded-r-lg border-y border-default first:border-l last:border-r',
td: 'border-b border-default align-top',
}"
>
<template #index-cell="{ row }">
<span class="text-xs tabular-nums text-gray-400 dark:text-gray-500">{{ row.index + 1 }}</span>
</template>
<template #status-cell="{ row }">
<span
v-if="row.original.failed"
class="inline-flex items-center justify-center size-5 rounded-full bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 text-xs leading-none"
title="Step failed"
>✗</span
>
<span
v-else
class="inline-flex items-center justify-center size-5 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 text-xs leading-none"
title="Step passed"
>✓</span
>
</template>
<template #category-cell="{ row }">
<UBadge :color="stepCategoryColor[row.original.category] || 'neutral'" variant="soft" size="xs">
{{ row.original.category }}
</UBadge>
</template>
<template #title-cell="{ row }">
<div :class="row.original.failed ? 'text-red-600 dark:text-red-400 font-medium' : ''">
{{ row.original.title }}
<div class="flex items-center gap-2">
<span :class="row.original.failed ? 'text-red-600 dark:text-red-400 font-medium' : ''">
{{ row.original.title }}
</span>
<UBadge
v-if="row.index === slowestStepIndex"
color="warning"
variant="subtle"
size="xs"
class="shrink-0"
title="Slowest step in this test"
>
slowest
</UBadge>
</div>
<p
v-if="row.original.failed && row.original.error?.message"
class="text-xs text-red-500 mt-1 whitespace-pre-wrap break-words font-mono"
>
{{ row.original.error.message }}
</p>
<OpenInIdeLink
v-if="row.original.location"
:location="row.original.location"
:project-key="testCase?.testRun?.project?.id"
:project-name="testCase?.testRun?.project?.name"
class="text-xs text-gray-400 dark:text-gray-500 mt-0.5"
/>
</template>
<template #duration-cell="{ row }">
<span
:class="`text-sm tabular-nums ${
row.original.duration > 2000
? 'text-red-600 font-medium'
: row.original.duration > 500
? 'text-orange-500'
: 'text-gray-500'
}`"
>
{{ formatDuration(row.original.duration) }}
</span>
<div class="min-w-[6rem]">
<div class="flex items-center justify-between gap-2">
<span :class="`text-sm tabular-nums ${stepDurationTextClass(row.original.duration)}`">
{{ formatDuration(row.original.duration) }}
</span>
<span
v-if="stepPctOfTest(row.original.duration)"
class="text-xs tabular-nums text-gray-400 dark:text-gray-500"
>
{{ stepPctOfTest(row.original.duration) }}
</span>
</div>
<div class="relative mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div
class="absolute inset-y-0 rounded-full"
:class="stepBarColorClass(row.original.duration)"
:style="stepBarStyle(row.original)"
/>
</div>
</div>
</template>
</UTable>
</TableScroller>
Expand Down
2 changes: 1 addition & 1 deletion application/server/database/schema.pg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ export const testRunsCases = pgTable(
retries: integer('retries').default(0),
line: integer('line'), // line number in file
column: integer('column'), // column number in file
steps: jsonb('steps'), // Array of { title, duration, category } step objects
steps: jsonb('steps'), // Array of { title, duration, category, location?, startTime? } step objects
stepEvents: jsonb('step_events'), // Array of { title, category, startedAt, duration, status, location } — hook/fixture steps for timeline
slowestStep: text('slowest_step'), // Title of the slowest step
slowestStepDuration: integer('slowest_step_duration'), // Duration of the slowest step in ms
Expand Down
2 changes: 1 addition & 1 deletion application/server/database/schema.sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ export const testRunsCases = sqliteTable(
retries: integer('retries').default(0),
line: integer('line'), // line number in file
column: integer('column'), // column number in file
steps: text('steps', { mode: 'json' }), // Array of { title, duration, category } step objects
steps: text('steps', { mode: 'json' }), // Array of { title, duration, category, location?, startTime? } step objects
stepEvents: text('step_events', { mode: 'json' }), // Array of { title, category, startedAt, duration, status, location } — hook/fixture steps for timeline
slowestStep: text('slowest_step'), // Title of the slowest step
slowestStepDuration: integer('slowest_step_duration'), // Duration of the slowest step in ms
Expand Down
4 changes: 4 additions & 0 deletions application/server/utils/run-json-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export interface TestStepInfo {
error?: { message?: string };
/** True when the step carried an error — the signal for inline failure markers. */
failed?: boolean;
/** Source pointer `file:line:col` (not a code snippet); present on runs from a recent reporter. */
location?: string;
/** Absolute start time in ms; present on runs from a recent reporter. */
startTime?: number;
}

export interface ConsoleLogEntry {
Expand Down
4 changes: 4 additions & 0 deletions application/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,10 @@ export interface PerformanceStep {
error?: { message?: string };
/** True when the step failed. */
failed?: boolean;
/** Source pointer `file:line:col` (not a code snippet); present on runs from a recent reporter. */
location?: string;
/** Absolute start time in ms; present on runs from a recent reporter. Enables per-step timing. */
startTime?: number;
}

/**
Expand Down
6 changes: 6 additions & 0 deletions reporter/src/internal/collect/step-analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ export interface FlatStep {
error?: { message: string };
/** True when the step carried an error — the signal the server needs for inline failure markers. */
failed?: boolean;
/** Source pointer `file:line:col` (not a code snippet); present when Playwright reports one. */
location?: string;
/** Absolute start time in ms; enables per-step timing/waterfall on the case detail page. */
startTime?: number;
}

/** Step-event category restricted to the values `extractTestStepEvents` emits. */
Expand All @@ -108,6 +112,8 @@ export function flattenSteps(steps: any[]): FlatStep[] {
flat.error = { message: step.error.message };
flat.failed = true;
}
if (step.location) flat.location = `${step.location.file}:${step.location.line}:${step.location.column}`;
if (step.startTime) flat.startTime = step.startTime instanceof Date ? step.startTime.getTime() : step.startTime;
result.push(flat);
if (step.steps?.length > 0) result.push(...flattenSteps(step.steps));
}
Expand Down
39 changes: 39 additions & 0 deletions reporter/tests/step-analyzer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,45 @@ describe('flattenSteps', () => {
const flat = flattenSteps([{ title: 'x', duration: 42, steps: [] }] as any);
expect(flat[0].duration).toBe(42);
});

it('captures the error message and failed flag for failing steps', () => {
const flat = flattenSteps([
{ title: 'locator.click', duration: 5, error: { message: 'element not found' }, steps: [] },
{ title: 'ok', duration: 1, steps: [] },
] as any);
expect(flat[0].error).toEqual({ message: 'element not found' });
expect(flat[0].failed).toBe(true);
expect(flat[1].error).toBeUndefined();
expect(flat[1].failed).toBeUndefined();
});

it('captures location (as file:line:col) and startTime when the raw step provides them', () => {
const start = new Date('2024-01-01T00:00:00.000Z');
const flat = flattenSteps([
{
title: 'Click',
category: 'action',
duration: 5,
startTime: start,
location: { file: 'a.spec.ts', line: 7, column: 3 },
steps: [],
},
{ title: 'plain', duration: 1, steps: [] },
] as any);
expect(flat[0].location).toBe('a.spec.ts:7:3');
expect(flat[0].startTime).toBe(start.getTime());
});

it('omits location/startTime entirely when the raw step lacks them (no undefined keys)', () => {
const flat = flattenSteps([{ title: 'plain', duration: 1, steps: [] }] as any);
expect('location' in flat[0]).toBe(false);
expect('startTime' in flat[0]).toBe(false);
});

it('accepts a numeric startTime (passes through)', () => {
const flat = flattenSteps([{ title: 'x', duration: 1, startTime: 12345, steps: [] }] as any);
expect(flat[0].startTime).toBe(12345);
});
});

describe('collectStepMetrics', () => {
Expand Down