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
2 changes: 1 addition & 1 deletion apps/application/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ those fields — intentional).
`app/components/shared/` holds the building blocks — **prefer them over re-implementing**. `SectionCard` /
`CollapsibleSectionCard` (headers + folding), `EmptyState` / `LoadingState` / `ErrorState`, `StatTile` + `StatTileGrid`
(never hand-rolled tile markup), `FilterToolbar`, `TableScroller`, `NavbarActions`, `BreadcrumbNav`, `ChartCard`,
`DurationValue`, `DiffPatch` / `DiffFile`, `HelpHint`, `DocLink`, `EnvManagedBadge` / `EnvManagedAlert`,
`DurationValue`, `ErrorText` (never print a raw error string — it carries ANSI codes), `DiffPatch` / `DiffFile`, `HelpHint`, `DocLink`, `EnvManagedBadge` / `EnvManagedAlert`,
`SettingsField`, `OpenInIdeLink`. See [`ARCHITECTURE.md`](ARCHITECTURE.md) for what each one does.

### Responsive / mobile (MUST follow)
Expand Down
10 changes: 7 additions & 3 deletions apps/application/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,12 @@ Shared building blocks worth knowing before writing new markup (`AGENTS.md` make
y-axis) / `ChartTooltip` / `ChartMarkerLines` / `ChartLegend` / `ChartMarkerTooltip`, `MiniRunBars`,
`DurationValue` (tight `210ms` via
the pure `splitDuration`), `CodeBlock`, `MarkdownPreview`, `DiffPatch` / `DiffFile`, `LocatorCode` (a locator
expression syntax-highlighted through `@piwitests/picker-dom`'s `tokenizeLocator`).
expression syntax-highlighted through `@piwitests/picker-dom`'s `tokenizeLocator`), `ErrorText` (error text with
its ANSI colors rendered — a one-line `line` preview for lists and cells, or the full `block`).
- **Navigation & actions** — `NavbarActions` (every `UDashboardNavbar` `#right` group; labels collapse to icons below
`sm`), `BreadcrumbNav` (drop-in for `UBreadcrumb`, collapses ancestors below `sm`), `OpenInIdeLink` +
`xl`), `BreadcrumbNav` (drop-in for `UBreadcrumb` and the page title of every detail page — the navbar never
repeats it; only the current crumb truncates, middle levels hide below `2xl`, ancestors collapse into a dropdown
below `sm`), `OpenInIdeLink` +
`OpenInIdeSettingsModal`, `DocLink`, `LinkChip` / `EntityLinks`.
- **Help & settings** — `HelpHint` (topic keys from `app/utils/help-content.ts`), `SettingsField`, `EnvManagedBadge`,
`EnvManagedAlert`.
Expand All @@ -184,7 +187,8 @@ Shared building blocks worth knowing before writing new markup (`AGENTS.md` make
(`useRunStream`, `useNotificationStream`), diagnosis (`useClusterDiagnosis`, `useStreamingDiagnosis`,
`useDiagnosisNotification`), timeline (`useTimelineModel`, `useTimelineViewport`), fold/tree state
(`useFoldedState`, `useFoldableSummary`, `useTreeViewCookie`), settings derivation (`useSettingsNav`,
`useSettingsEnvState`), analytics scope, IDE preferences (`useOpenInIde`), desktop detection (`useIsDesktop`,
`useSettingsEnvState`), analytics scope, the run page's retry command (`useRunRetryCommand` — one failing set and one
shared mode for every copy button on the page), IDE preferences (`useOpenInIde`), desktop detection (`useIsDesktop`,
`useTauri`), demo helpers, and small utilities (`useCopy` / `useCopyRich` — use these instead of hand-rolling
`navigator.clipboard`, `useAiStatus`, `useChartTooltip`).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,7 @@ const evidenceChips = computed(() => {
<div class="divide-y divide-default">
<div v-for="(step, idx) in failingSteps" :key="idx" class="px-3 py-2 space-y-0.5">
<p class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ idx + 1 }}. {{ step.title }}</p>
<p v-if="step.error?.message" class="text-xs font-mono text-red-500 whitespace-pre-wrap break-all">
{{ step.error.message }}
</p>
<ErrorText v-if="step.error?.message" mode="block" :text="step.error.message" />
</div>
</div>
</TestEvidenceSection>
Expand Down
37 changes: 11 additions & 26 deletions apps/application/app/components/run/FailureGroups.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<script setup lang="ts">
import type { TableColumn } from '@nuxt/ui';
import type { FailureGroup } from '~~/types/api';
import { buildRetryCommand } from '~/utils/retry-command';
import type { FailureGroup, TestCaseResult } from '~~/types/api';

const emit = defineEmits<{
selectCluster: [clusterId: number];
Expand All @@ -10,6 +9,8 @@ const emit = defineEmits<{
const props = defineProps<{
/** Increments when the run finishes so this tab can refetch. */
refreshKey?: number;
/** The run's execution rows — the retry command is built from these, same as the summary's. */
testCases: TestCaseResult[];
}>();

const route = useRoute();
Expand All @@ -34,28 +35,12 @@ watch(
);

const diagnosisClusterId = ref<number | null>(null);
const { copy, copied } = useCopy();

const allFailedCases = computed(() => {
if (!groups.value) return [];
return groups.value.flatMap((g) =>
g.cases
.filter((c) => !c.passedOnRetry)
.map((c) => ({
filePath: c.filePath,
title: c.title,
line: null,
projectName: null,
})),
);
});

const retryCommand = computed(() => buildRetryCommand(allFailedCases.value));

function copyRetryCommand() {
const cmd = retryCommand.value;
if (cmd) copy(cmd, { toast: 'Retry command copied' });
}
const {
failedCases,
copyCommand: copyRetryCommand,
copied,
title: retryTitle,
} = useRunRetryCommand(() => props.testCases);

const columns: TableColumn<FailureGroup>[] = [
{ accessorKey: 'signature', header: createSortHeader<FailureGroup>('Signature') },
Expand Down Expand Up @@ -86,12 +71,12 @@ const totalCases = computed(() => groups.value?.reduce((sum, g) => sum + g.caseC
<HelpHint topic="cluster.concept" />
</p>
<UButton
v-if="allFailedCases.length > 0"
v-if="failedCases.length > 0"
size="xs"
variant="outline"
color="neutral"
:icon="copied ? 'i-lucide-check' : 'i-lucide-play'"
:title="copied ? 'Copied!' : copyPreview(retryCommand)"
:title="retryTitle"
@click="copyRetryCommand()"
>
Copy retry command
Expand Down
41 changes: 7 additions & 34 deletions apps/application/app/components/run/RunSummary.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<script setup lang="ts">
import type { TestRunDetails, ReportInfo } from '~~/types/api';
import type { RetryMode } from '~/utils/retry-command';
import { buildRetryCommand } from '~/utils/retry-command';

const props = defineProps<{
testRun: TestRunDetails;
Expand Down Expand Up @@ -38,8 +37,13 @@ const showStorage = computed(() => !!(storageStats.value?.totalFiles || props.fi
const primaryReport = computed(() => props.allReports[0] ?? null);

const { copy, copied } = useCopy();
const retryMode = ref<RetryMode>('file-line');
const retryCopied = ref(false);
const {
mode: retryMode,
failedCases,
copyCommand: copyRetryCommand,
copied: retryCopied,
title: retryTitle,
} = useRunRetryCommand(() => props.testRun?.testCases);

// Inside the desktop shell the run-locally split button covers copying the
// command ("Copy as command"), so the copy-only Retry button stays web-only.
Expand All @@ -48,37 +52,6 @@ onMounted(() => {
desktopBridge.value = !!tauriCore();
});

const failedCases = computed(() => {
if (!props.testRun?.testCases) return [];
return props.testRun.testCases
.filter((tc) => tc.status === 'failed' || tc.status === 'timedout')
.map((tc) => ({
filePath: (tc.filePath || tc.location?.split(':')[0]) ?? '',
title: tc.title,
line: tc.location ? parseInt(tc.location.split(':')[1] ?? '', 10) || null : null,
projectName: (tc.browser as { projectName?: string } | null)?.projectName || null,
}));
});

function buildRetry() {
return buildRetryCommand(failedCases.value, { mode: retryMode.value });
}

async function copyRetryCommand() {
const cmd = buildRetry();
if (!cmd) return;
retryCopied.value = true;
await copy(cmd, { toast: 'Retry command copied' });
setTimeout(() => {
retryCopied.value = false;
}, 2000);
}

const retryTitle = computed(() => {
if (retryCopied.value) return 'Copied!';
return copyPreview(buildRetry());
});

function buildRunSummary() {
const run = props.testRun;
if (!run) return '';
Expand Down
17 changes: 3 additions & 14 deletions apps/application/app/components/run/TestCasesList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,7 @@ defineExpose({ scrollToCase });
:live-steps="liveSteps"
:project-key="projectKey"
:project-name="projectName"
:cluster-names="clusterNames"
class="flex-1 min-h-0"
/>

Expand Down Expand Up @@ -565,13 +566,7 @@ defineExpose({ scrollToCase });
<BrowserBadge :browser="item.browser" size="sm" class="mt-0.5" />
</div>

<p
v-if="isFailedStatus(item.status) && item.error"
class="pl-6 text-xs text-rose-600 dark:text-rose-400 truncate"
:title="item.error"
>
{{ item.error }}
</p>
<ErrorText v-if="isFailedStatus(item.status) && item.error" :text="item.error" class="pl-6" />

<OpenInIdeLink
v-if="item.location"
Expand Down Expand Up @@ -665,13 +660,7 @@ defineExpose({ scrollToCase });
</UBadge>
</NuxtLink>
</div>
<p
v-if="isFailedStatus(item.status) && item.error"
class="text-xs text-rose-600 dark:text-rose-400 truncate"
:title="item.error"
>
{{ item.error }}
</p>
<ErrorText v-if="isFailedStatus(item.status) && item.error" :text="item.error" />
<OpenInIdeLink
v-if="item.location"
:location="item.location"
Expand Down
23 changes: 23 additions & 0 deletions apps/application/app/components/run/TestCasesTree.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,16 @@ const props = defineProps<{
/** Piwi project id + name, threaded so the IDE opener can resolve a workspace root. */
projectKey?: string | number | null;
projectName?: string | null;
/** Failure-cluster id → display name, for the cluster badges on failing rows. */
clusterNames?: Record<number, string> | null;
}>();

// A failing row always identifies its cluster from `failureClusterId` alone —
// a name the page has not resolved yet falls back to the id, never to no badge.
function clusterLabel(id: number): string {
return props.clusterNames?.[id] ?? `Cluster #${id}`;
}

const suiteLookup = computed(() => {
const map = new Map<string, { mode: string; annotations: Array<{ type: string; description?: string }> }>();
for (const s of props.suites) {
Expand Down Expand Up @@ -326,6 +334,15 @@ const flatRows = computed<FlatRow[]>(() => {
:max-tags="3"
class="shrink-0"
/>
<NuxtLink
v-if="row.test.failureClusterId"
:to="`/failure-clusters/${row.test.failureClusterId}`"
class="shrink-0"
>
<UBadge color="info" variant="subtle" size="xs" class="max-w-44">
<span class="truncate">{{ clusterLabel(row.test.failureClusterId) }}</span>
</UBadge>
</NuxtLink>
<div class="flex items-center gap-1.5 sm:gap-2 shrink-0 ml-auto">
<template v-if="row.test.status === 'running'">
<TestRowLiveStep
Expand Down Expand Up @@ -358,6 +375,12 @@ const flatRows = computed<FlatRow[]>(() => {
<DurationValue :ms="row.test.wastedTimeMs" unit-class="opacity-60" no-title />
</span>
</div>
<!-- The one-line error under a failing row, the same as the flat list shows -->
<ErrorText
v-if="isFailedStatus(row.test.status) && row.test.error"
:text="row.test.error"
class="basis-full pl-6"
/>
</div>
</template>

Expand Down
50 changes: 35 additions & 15 deletions apps/application/app/components/shared/BreadcrumbNav.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
<script setup lang="ts">
/**
* Responsive breadcrumb. From `sm` up it renders the standard `UBreadcrumb`
* (all levels, any custom item slots forwarded). Below `sm`, where a deep
* path is unreadable, it collapses the ancestor levels into a dropdown and
* shows only the current page label — tapping the leading chip reveals the
* full path. Drop-in replacement for `<UBreadcrumb :items>`.
* Responsive breadcrumb — the page title of every detail page, so the navbar
* never repeats it. From `sm` up it renders the standard `UBreadcrumb` (any
* custom item slots forwarded) and takes the room the navbar actions leave:
* ancestors keep their natural width (capped) and the current page label
* takes the rest, down to a readable floor. Below `2xl` the root keeps just
* its icon and the middle levels beyond the nearest two ancestors are hidden,
* so a deep path still reads on a laptop next to the actions. Below `sm` the ancestors collapse into a
* dropdown and only the current page label shows — tapping the leading chip
* reveals the full path. Drop-in replacement for `<UBreadcrumb :items>`.
*/
import type { BreadcrumbItem, DropdownMenuItem } from '@nuxt/ui';

Expand All @@ -23,22 +27,38 @@ const ancestorItems = computed<DropdownMenuItem[]>(() =>

const hasAncestors = computed(() => ancestorItems.value.length > 0);

// The leaf truncates so a long page title never collides with the navbar
// actions; every level needs `min-w-0` for the truncation to take effect.
const truncatingItems = computed(() => {
const items = props.items.map((item) => ({ ...item }));
const last = items[items.length - 1];
if (last) last.class = 'truncate';
return items;
// The ancestors are the path and stay readable: natural width, capped so a
// long project name ellipsizes (an item with a custom slot sizes itself).
// The current page label is the title and takes the remaining room, down to
// a floor, truncating from the end. Below `2xl` the root keeps its icon only
// and the middle levels hide, together with the separator each one owns.
// Every level needs `min-w-0` for the truncation to take effect.
const desktopItems = computed(() => {
const count = props.items.length;
return props.items.map((item, index) => {
if (index === count - 1) return { ...item, class: 'truncate', ui: { ...item.ui, item: 'min-w-40' } };
const rootIconOnly = index === 0 && item.icon ? 'max-2xl:sr-only' : '';
const hidden = index > 0 && index < count - 3 ? 'max-2xl:hidden' : '';
return {
...item,
class: item.slot ? undefined : 'max-w-48',
ui: {
...item.ui,
item: ['shrink-0', hidden].join(' ').trim(),
separator: hidden,
linkLabel: ['truncate', rootIconOnly].join(' ').trim(),
},
};
});
});
</script>

<template>
<!-- Desktop: full breadcrumb, forwarding any custom per-item slots (e.g. #project). -->
<UBreadcrumb
:items="truncatingItems"
class="hidden min-w-0 sm:flex"
:ui="{ root: 'min-w-0', list: 'min-w-0', item: 'min-w-0', link: 'min-w-0' }"
:items="desktopItems"
class="hidden min-w-0 flex-1 overflow-hidden sm:flex"
:ui="{ root: 'min-w-0', list: 'min-w-0', item: 'min-w-0', link: 'min-w-0', separator: 'shrink-0' }"
>
<template v-for="(_, name) in $slots" #[name]="slotData">
<slot :name="name" v-bind="slotData ?? {}" />
Expand Down
15 changes: 14 additions & 1 deletion apps/application/app/components/shared/EnvironmentDiffCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ const {
lazy: true,
});

// The card renders only for a usable diff; the page reads `available` to show
// its jump chip for exactly the same condition.
const emit = defineEmits<{ available: [value: boolean] }>();
const available = computed(() => !pending.value && !error.value && diff.value?.status === 'ok');
watch(available, (value) => emit('available', value), { immediate: true });

const entries = computed<EnvironmentDiffEntry[]>(() => diff.value?.entries ?? []);
const meaningful = computed(() => entries.value.filter((e) => !e.informational));
const informational = computed(() => entries.value.filter((e) => e.informational));
Expand All @@ -60,7 +66,7 @@ defineExpose({ reveal: () => card.value?.reveal?.() });
<template>
<component
:is="cardComponent"
v-if="!pending && !error && diff?.status === 'ok'"
v-if="available && diff"
ref="card"
v-bind="cardBind"
icon="i-lucide-git-compare-arrows"
Expand All @@ -83,6 +89,13 @@ defineExpose({ reveal: () => card.value?.reveal?.() });
/>

<div v-else class="space-y-2">
<!-- Which side is which: the failing run on the left, its last pass on the right -->
<div class="flex items-center gap-1.5 text-xs font-medium px-3">
<span class="hidden sm:block sm:w-40 shrink-0" aria-hidden="true" />
<span class="text-red-700 dark:text-red-400">This run</span>
<UIcon name="i-lucide-arrow-left" class="size-3 shrink-0 text-gray-400" />
<span class="text-green-700 dark:text-green-400">Last pass, run #{{ diff?.baseline?.runId }}</span>
</div>
<div
v-for="entry in meaningful"
:key="entry.key"
Expand Down
Loading
Loading