diff --git a/apps/application/AGENTS.md b/apps/application/AGENTS.md
index 3c1225b2..172187ab 100644
--- a/apps/application/AGENTS.md
+++ b/apps/application/AGENTS.md
@@ -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)
diff --git a/apps/application/ARCHITECTURE.md b/apps/application/ARCHITECTURE.md
index 648fa2c6..bc78abea 100644
--- a/apps/application/ARCHITECTURE.md
+++ b/apps/application/ARCHITECTURE.md
@@ -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`.
@@ -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`).
diff --git a/apps/application/app/components/cluster/ClusterTestEvidence.vue b/apps/application/app/components/cluster/ClusterTestEvidence.vue
index 56bc6c72..4c35162a 100644
--- a/apps/application/app/components/cluster/ClusterTestEvidence.vue
+++ b/apps/application/app/components/cluster/ClusterTestEvidence.vue
@@ -243,9 +243,7 @@ const evidenceChips = computed(() => {
{{ idx + 1 }}. {{ step.title }}
-
- {{ step.error.message }}
-
+
diff --git a/apps/application/app/components/run/FailureGroups.vue b/apps/application/app/components/run/FailureGroups.vue
index 03948a13..40c77f8a 100644
--- a/apps/application/app/components/run/FailureGroups.vue
+++ b/apps/application/app/components/run/FailureGroups.vue
@@ -1,7 +1,6 @@
diff --git a/apps/application/app/components/shared/EnvironmentDiffCard.vue b/apps/application/app/components/shared/EnvironmentDiffCard.vue
index c03fd25d..7b67deae 100644
--- a/apps/application/app/components/shared/EnvironmentDiffCard.vue
+++ b/apps/application/app/components/shared/EnvironmentDiffCard.vue
@@ -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(() => diff.value?.entries ?? []);
const meaningful = computed(() => entries.value.filter((e) => !e.informational));
const informational = computed(() => entries.value.filter((e) => e.informational));
@@ -60,7 +66,7 @@ defineExpose({ reveal: () => card.value?.reveal?.() });
card.value?.reveal?.() });
/>
+
+
+
+ This run
+
+ Last pass, run #{{ diff?.baseline?.runId }}
+
+/**
+ * Error text as Playwright printed it, with its ANSI colors rendered instead
+ * of leaking as `[31m` fragments. The base color is muted — the status icon
+ * next to it already says "failed" — and the ANSI highlights (received in red,
+ * expected in green, dimmed punctuation) carry the emphasis.
+ *
+ * `line` (default) is the one-line preview for lists and table cells: the
+ * whole message collapsed onto one line and truncated, so the locator or the
+ * received value that follows the first line still shows, with the plain full
+ * text as a tooltip. `block` keeps every line for a step's or an error card's
+ * full message.
+ */
+import { renderAnsi } from '~/utils';
+import { stripAnsi } from '~/utils/text-format';
+
+const props = withDefaults(defineProps<{ text: string; mode?: 'line' | 'block' }>(), { mode: 'line' });
+
+const html = computed(() => renderAnsi(props.text));
+const plain = computed(() => stripAnsi(props.text).trim());
+
+
+
+
+
+
+
+
diff --git a/apps/application/app/components/shared/ExportMenu.vue b/apps/application/app/components/shared/ExportMenu.vue
index 3568a189..4b71793c 100644
--- a/apps/application/app/components/shared/ExportMenu.vue
+++ b/apps/application/app/components/shared/ExportMenu.vue
@@ -67,8 +67,15 @@ function copyReport() {
-
- Export
+
+ Export
diff --git a/apps/application/app/components/shared/LocatorHealingPanel.vue b/apps/application/app/components/shared/LocatorHealingPanel.vue
index 0be4afc3..64490cad 100644
--- a/apps/application/app/components/shared/LocatorHealingPanel.vue
+++ b/apps/application/app/components/shared/LocatorHealingPanel.vue
@@ -5,7 +5,7 @@
* note. Used on both the cluster detail page and the test-case detail page.
*/
-import { recommendLocatorFix } from '#shared/locator-healing';
+import { recommendLocatorFix, locatorExpression } from '#shared/locator-healing';
import type { RankedLocator, LocatorFixRecommendation, LocatorHealingResult } from '#shared/locator-healing.types';
import type { AiStepIntent, TraceInfo } from '~~/types/api';
import SectionCard from './SectionCard.vue';
@@ -142,15 +142,16 @@ const sourceClass = computed(() => {
}
});
+/** The failing locator as Playwright source, the same form every alternative renders in. */
const failingLocatorText = computed(() => {
const f = healing.value?.failingLocator;
- return f ? `${f.method}(${JSON.stringify(f.args)})` : '';
+ return f ? locatorExpression(f.method, f.args) : '';
});
/**
- * Quote/whitespace/bracket-insensitive form so the failing locator (JSON-ish
- * rendering) can be compared against an AI-step intent locator (Playwright
- * source style) — both collapse to `getbyrole(textbox,name:email)`.
+ * Quote/whitespace/bracket-insensitive form so the failing locator can be
+ * compared against an AI-step intent locator regardless of quoting or spacing
+ * — both collapse to `getbyrole(textbox,name:email)`.
*/
function normalizeLocator(text: string): string {
return text.toLowerCase().replace(/[\s'"`{}[\]]/g, '');
@@ -403,7 +404,7 @@ const visibleAlternatives = computed(() =>
- {{ failingLocatorText }}
+
/**
* Responsive action row for `UDashboardNavbar` `#right` slots. Renders each
- * action as a `UButton` whose text label collapses below the `sm` breakpoint
+ * action as a `UButton` whose text label collapses below the `xl` breakpoint
* (icon-only, `aria-label`/`title` preserved), so page actions never crowd
* the breadcrumb on phones. Extra custom controls can be placed in the
* `leading` (before) and default (after) slots.
@@ -46,7 +46,7 @@ withDefaults(
:title="action.title ?? action.label"
@click="action.onClick?.()"
>
- {{ action.label }}
+ {{ action.label }}
diff --git a/apps/application/app/components/shared/ShareLinksModal.vue b/apps/application/app/components/shared/ShareLinksModal.vue
index 3926b5ef..938019cd 100644
--- a/apps/application/app/components/shared/ShareLinksModal.vue
+++ b/apps/application/app/components/shared/ShareLinksModal.vue
@@ -113,9 +113,10 @@ function linkState(link: ShareLinkSummary): { label: string; color: 'success' |
color="neutral"
variant="outline"
title="Hand this investigation to someone without an account"
+ aria-label="Share"
@click="open = true"
>
- Share
+ Share
diff --git a/apps/application/app/components/test-case/DomSnapshotCard.vue b/apps/application/app/components/test-case/DomSnapshotCard.vue
index b7f37004..86ec0015 100644
--- a/apps/application/app/components/test-case/DomSnapshotCard.vue
+++ b/apps/application/app/components/test-case/DomSnapshotCard.vue
@@ -35,6 +35,14 @@ const {
lazy: true,
});
+// The card renders only for a usable snapshot; 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 && snapshot.value?.status === 'ok' && !!snapshot.value.html,
+);
+watch(available, (value) => emit('available', value), { immediate: true });
+
// Highlighting hundreds of KB of HTML would freeze the tab — show a capped
// excerpt inline and offer the full document via copy.
const DISPLAY_CAP = 20_000;
@@ -58,7 +66,7 @@ defineExpose({ reveal: () => card.value?.reveal?.() });
- Use Export → AI context in the header to grab the full evidence bundle for your own AI
+ Use Copy prompt in this card's header to grab the full evidence bundle for your own AI
tool, or configure a provider.
+ mode="block"
+ :text="row.original.error.message"
+ class="mt-1"
+ />
-
- {{
- row.original.error.length > 80 ? `${row.original.error.substring(0, 80)}…` : row.original.error
- }}
-
+
diff --git a/apps/application/app/pages/test-runs/[id].vue b/apps/application/app/pages/test-runs/[id].vue
index d11edff7..c6e1f412 100644
--- a/apps/application/app/pages/test-runs/[id].vue
+++ b/apps/application/app/pages/test-runs/[id].vue
@@ -24,17 +24,10 @@ const latestRunId = computed(() => latestRunInfo.value?.id ?? testRun.value?.pro
const latestRunStatus = computed(() => latestRunInfo.value?.status ?? testRun.value?.project?.latestRunStatus ?? null);
const isLatestRunActive = computed(() => latestRunStatus.value === 'running' || latestRunStatus.value === 'finalizing');
-const navbarTitle = computed(() => {
- // The page heading must name this specific run — the breadcrumb and the
- // URL carry the project identity.
- const project = testRun.value?.project?.label || testRun.value?.project?.name;
- return project ? `Run #${runId} — ${project}` : `Run #${runId}`;
-});
-
useHead(
computed(() => {
- // Match the on-page heading: prefer the project's display label so a
- // labeled project reads the same in the tab title and the navbar.
+ // The tab title names the run and its project (display label first); on
+ // the page the breadcrumb carries the project.
const project = testRun.value?.project?.label || testRun.value?.project?.name;
return {
title: `Test run #${runId}${project ? ` — ${project}` : ''} — Piwi Dashboard`,
@@ -667,7 +660,8 @@ function handleSelectCluster(clusterId: number) {
-
+
+
-
+
diff --git a/apps/application/shared/handlers/test-cases.ts b/apps/application/shared/handlers/test-cases.ts
index 1ef958fb..76647e74 100644
--- a/apps/application/shared/handlers/test-cases.ts
+++ b/apps/application/shared/handlers/test-cases.ts
@@ -158,6 +158,26 @@ export async function getTestRunCase(
// Large evidence payloads are content-addressed; legacy rows keep them inline.
const evidence = await inlineCasePayloads(db, trc);
+ // Every attempt is its own execution row (unique on run + test case + retries
+ // + browser), so each stored attempt maps to the sibling row that holds it.
+ const siblingRows = await db
+ .select({ id: testRunsCases.id, retries: testRunsCases.retries })
+ .from(testRunsCases)
+ .where(
+ and(
+ eq(testRunsCases.testRunId, trc.testRunId),
+ eq(testRunsCases.testCaseId, trc.testCaseId),
+ trc.browserName ? eq(testRunsCases.browserName, trc.browserName) : sql`${testRunsCases.browserName} IS NULL`,
+ ),
+ );
+ const executionByRetry = new Map(siblingRows.map((r: any) => [r.retries ?? 0, r.id as number]));
+ const attempts = Array.isArray(trc.attempts)
+ ? (trc.attempts as Array<{ retry: number }>).map((a) => ({
+ ...a,
+ executionId: executionByRetry.get(a.retry) ?? null,
+ }))
+ : null;
+
const [[testCase], [testRun], reportList, attachmentList] = await Promise.all([
db
.select()
@@ -342,7 +362,7 @@ export async function getTestRunCase(
duration: trc.duration,
error: trc.error,
retries: trc.retries,
- attempts: trc.attempts ?? null,
+ attempts,
steps: trc.steps,
testSource: evidence.testSource,
testSourceFrames: evidence.testSourceFrames,
diff --git a/apps/application/shared/locator-healing.ts b/apps/application/shared/locator-healing.ts
index 367ffe1b..2d8094b1 100644
--- a/apps/application/shared/locator-healing.ts
+++ b/apps/application/shared/locator-healing.ts
@@ -204,3 +204,52 @@ export async function locatorSignatureFromExpression(expr: string): Promise = {
+ getByTestId: 'testId',
+ getByRole: 'role',
+ getByText: 'text',
+ getByLabel: 'label',
+ getByPlaceholder: 'placeholder',
+ getByAltText: 'text',
+ getByTitle: 'title',
+ locator: 'selector',
+ 'page.locator': 'selector',
+};
+
+/** A parsed arg value as Playwright source: quoted string, bare literal, or regex text as-is. */
+function locatorArgLiteral(value: unknown): string {
+ if (typeof value === 'string') {
+ if (value.startsWith('/') && /\/[a-z]*$/.test(value) && value.length > 1) return value;
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
+ }
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
+ if (value == null) return 'undefined';
+ return JSON.stringify(value);
+}
+
+/**
+ * Render a parsed locator back to the Playwright expression a developer would
+ * write, e.g. `{ method: 'getByRole', args: { role: 'row', name: 'Total' } }`
+ * → `getByRole('row', { name: 'Total' })`. The inverse of the server-side
+ * expression parser, so the failing locator reads like every alternative.
+ */
+export function locatorExpression(method: string, args: Record | null | undefined): string {
+ const a = args ?? {};
+ const parts: string[] = [];
+ if (Array.isArray(a.args) && !(method in LOCATOR_PRIMARY_ARG)) {
+ return `${method}(${a.args.map(locatorArgLiteral).join(', ')})`;
+ }
+ const primary = LOCATOR_PRIMARY_ARG[method];
+ if (primary && a[primary] !== undefined) parts.push(locatorArgLiteral(a[primary]));
+ const options = Object.entries(a).filter(([key, value]) => key !== primary && value !== undefined);
+ if (options.length) {
+ parts.push(`{ ${options.map(([key, value]) => `${key}: ${locatorArgLiteral(value)}`).join(', ')} }`);
+ }
+ return `${method}(${parts.join(', ')})`;
+}
diff --git a/apps/application/tests/test-run-case-page.spec.ts b/apps/application/tests/test-run-case-page.spec.ts
index 5a71f279..afb47f14 100644
--- a/apps/application/tests/test-run-case-page.spec.ts
+++ b/apps/application/tests/test-run-case-page.spec.ts
@@ -96,10 +96,10 @@ test.describe('Test-run-case page', () => {
await expect(page.getByRole('button', { name: /^Steps/ })).toBeVisible();
});
- test('the header offers a Copy retry command action', async ({ page }) => {
+ test('the summary offers a Copy retry command action', async ({ page }) => {
await page.goto(`/test-run-cases/${failedCaseId}`);
await waitForHydration(page);
- // NavbarActions keeps the label as accessible name even when icon-only on mobile.
+ // The button keeps its label as accessible name even when icon-only in a narrow card.
await expect(page.getByRole('button', { name: /Copy retry command/ })).toBeVisible();
});
diff --git a/apps/application/tests/unit/locator-healing.test.ts b/apps/application/tests/unit/locator-healing.test.ts
index 26905391..a8c52a2d 100644
--- a/apps/application/tests/unit/locator-healing.test.ts
+++ b/apps/application/tests/unit/locator-healing.test.ts
@@ -6,6 +6,7 @@ import {
locatorIdentityEquals,
alternativeUsesName,
CONVENTION_STABILITY_FLOOR,
+ locatorExpression,
} from '#shared/locator-healing';
import type { RankedLocator } from '#shared/locator-healing.types';
import { extractLeafSelector } from '#shared/error-fingerprint';
@@ -296,3 +297,28 @@ describe('alternativeUsesName', () => {
expect(alternativeUsesName(alt('getByLabel', { label: 'Email' }), ' ')).toBe(false);
});
});
+
+describe('locatorExpression', () => {
+ test('renders the positional arg and options as Playwright source', () => {
+ expect(locatorExpression('getByRole', { role: 'row' })).toBe("getByRole('row')");
+ expect(locatorExpression('getByRole', { role: 'button', name: 'Pay now', exact: true })).toBe(
+ "getByRole('button', { name: 'Pay now', exact: true })",
+ );
+ expect(locatorExpression('getByRole', { role: 'heading', level: 2 })).toBe("getByRole('heading', { level: 2 })");
+ expect(locatorExpression('getByLabel', { label: 'Email address' })).toBe("getByLabel('Email address')");
+ expect(locatorExpression('getByTestId', { testId: 'submit' })).toBe("getByTestId('submit')");
+ expect(locatorExpression('locator', { selector: '.btn-primary' })).toBe("locator('.btn-primary')");
+ });
+
+ test('keeps regex options verbatim and escapes quotes', () => {
+ expect(locatorExpression('getByText', { text: "it's here" })).toBe("getByText('it\\'s here')");
+ expect(locatorExpression('getByRole', { role: 'link', name: '/^Docs/i' })).toBe(
+ "getByRole('link', { name: /^Docs/i })",
+ );
+ });
+
+ test('falls back to positional args for an unknown method', () => {
+ expect(locatorExpression('frameLocator', { args: ['#frame'] })).toBe("frameLocator('#frame')");
+ expect(locatorExpression('getByRole', {})).toBe('getByRole()');
+ });
+});
diff --git a/apps/application/types/api.ts b/apps/application/types/api.ts
index 2a042eea..e538a94b 100644
--- a/apps/application/types/api.ts
+++ b/apps/application/types/api.ts
@@ -701,6 +701,19 @@ export interface AiStepIntent {
/**
* Test case result (for a specific test run)
*/
+/**
+ * One attempt of a test within a run. Every attempt is its own execution row;
+ * `executionId` is that sibling row's id (null when the row is not stored,
+ * e.g. rows recorded before attempts were kept).
+ */
+export interface AttemptOutcome {
+ retry: number;
+ status: string;
+ duration: number;
+ startedAt: number | null;
+ executionId?: number | null;
+}
+
export interface TestCaseResult {
/** The execution id (a test_runs_cases row): this test case run within this run. */
executionId: number;
@@ -722,8 +735,8 @@ export interface TestCaseResult {
testSourceFrames?: TestSourceFrame[] | null;
failureClusterId?: number | null;
retries?: number | null;
- /** Per-attempt outcomes `{ retry, status, duration, startedAt }`, oldest first. */
- attempts?: Array<{ retry: number; status: string; duration: number; startedAt: number | null }> | null;
+ /** Per-attempt outcomes, oldest first. */
+ attempts?: AttemptOutcome[] | null;
steps?: PerformanceStep[] | null;
stepEvents?: TestStepEvent[] | null;
slowestStep?: string | null;
@@ -1082,8 +1095,8 @@ export interface TestCaseHistoryPoint {
duration: number | null;
error: string | null;
retries: number | null;
- /** Per-attempt outcomes `{ retry, status, duration, startedAt }`, oldest first. */
- attempts?: Array<{ retry: number; status: string; duration: number; startedAt: number | null }> | null;
+ /** Per-attempt outcomes, oldest first. */
+ attempts?: AttemptOutcome[] | null;
startTime: string | Date;
runStatus: string;
}
diff --git a/apps/docs/evidence.md b/apps/docs/evidence.md
index 130df015..26757387 100644
--- a/apps/docs/evidence.md
+++ b/apps/docs/evidence.md
@@ -23,8 +23,8 @@ Most links from a run land on an execution; the test's title links to the test c
Everything about a single test execution, laid out **diagnosis-first**. A pinned **summary** carries the status, title, copyable location, duration, worker, retries and duration-vs-average, plus at-a-glance **signal badges** (new regression, new flaky, passed-on-retry), any test annotations (`@fixme`, `@slow`, …), the **wasted time** spent in fixed waits, and metadata cards (environment, CI, branch, commit, author, browser, storage). Traces stream in live while the parent run is still running.
-
- A failing execution, diagnosis-first — the summary with its wasted-time readout, the error, and the captured evidence folded into one screen.
+
+ A failing execution, diagnosis-first — the summary with its wasted-time readout, the error, the test source and screenshots open, and the rest of the captured evidence folded into one screen.
The tabs adapt to the result.
@@ -34,11 +34,11 @@ The tabs adapt to the result.
- **Verdict** — is this a new regression or flaky, how many times it retried, and how long the test has been failing, with a clickable recent-runs strip to jump between executions.
- **Failure cluster** *(when the failure is clustered)* — signature, error type, how many tests it hit, the cluster's own AI verdict, and a hand-off to the full cross-test investigation.
- **AI diagnosis** — diagnose *this execution* with one click, or **Copy AI context** to paste the full evidence bundle into your own assistant (works even with no provider configured). Cited evidence links jump to the matching section on the page.
-- **Evidence funnel** — the **test source** as a call stack (the line that actually threw plus the callers above it, so a failure inside a helper is visible, not just the test line that invoked it — and, [with a trace](#trace-powered-deep-views), the complete stack with real source), grouped **failure evidence** (screenshots, video, traces, attachments), [alternative locators](./reporter#locator-healing) for a broken locator, an **environment diff** and **visual diff** against the last green run, **console** output, **network requests** with inline [backend logs](./backend-logs) and a [Full trace](#trace-powered-deep-views) network view, **app state**, the failure-time **ARIA snapshot**, and the reconstructed **DOM snapshot**.
+- **Evidence funnel** — every section is a card that folds to a one-line peek and remembers your choice; the test source and the failure evidence open expanded so the failing line and the screenshot are on the first screen, the rest starts folded. It runs from the **test source** as a call stack (the line that actually threw plus the callers above it, so a failure inside a helper is visible, not just the test line that invoked it — and, [with a trace](#trace-powered-deep-views), the complete stack with real source), grouped **failure evidence** (screenshots, video, traces, attachments), [alternative locators](./reporter#locator-healing) for a broken locator, an **environment diff** and **visual diff** against the last green run, **console** output, **network requests** with inline [backend logs](./backend-logs) and a [Full trace](#trace-powered-deep-views) network view, **app state**, the failure-time **ARIA snapshot**, and the reconstructed **DOM snapshot**.
**A passing execution opens on Steps**, with an **Artifacts** tab for its traces, attachments, console and network.
-Both keep a **Performance** tab (performance hints plus color-coded **Web Vitals**) and a **History** tab (this test's status and duration trend over recent runs, linking through to the full test history). A **Copy retry command** button in the header gives you the exact Playwright command to re-run just this test. The Web Vitals, network, console, ARIA-snapshot and alternative-locator data all come from the [capture fixtures](./capture-fixtures).
+Both keep a **Performance** tab (performance hints plus color-coded **Web Vitals**) and a **History** tab (this test's status and duration trend over recent runs, linking through to the full test history). A **Copy retry command** button in the summary gives you the exact Playwright command to re-run just this test. The Web Vitals, network, console, ARIA-snapshot and alternative-locator data all come from the [capture fixtures](./capture-fixtures).
## Trace-powered deep views
diff --git a/apps/docs/share-links.md b/apps/docs/share-links.md
index 0dd95cbb..b06d2e89 100644
--- a/apps/docs/share-links.md
+++ b/apps/docs/share-links.md
@@ -12,6 +12,9 @@ export time, the link renders the investigation as it stands when it is opened.
Share links are **off by default**. Set `PIWI_SHARE_LINKS_ENABLED=true` to allow them — see the
[configuration reference](./configuration#authentication).
+The [live demo](https://piwitests.dev/demo/) runs without a server, so it has no share links and hides the **Share**
+button.
+
## Creating a link
On an execution page or a failure-cluster page, open **Share** (next to **Export**):
diff --git a/apps/docs/ui-overview.md b/apps/docs/ui-overview.md
index 44867527..2502a400 100644
--- a/apps/docs/ui-overview.md
+++ b/apps/docs/ui-overview.md
@@ -116,7 +116,9 @@ A failing execution opens diagnosis-first: the error, a verdict, the cluster it
evidence funnel running from the call stack down to the ARIA snapshot — deeper still when a trace is
attached. All of it, plus the bundled trace viewer, is described in
[Failure evidence](./evidence). The summary shows one chip per **attempt** (with its outcome and
-duration) when a test retried, so "how did this execution get here" is answerable at a glance.
+duration) when a test retried, so "how did this execution get here" is answerable at a glance; every
+attempt is its own execution, and each chip links to that attempt's page while the one you are viewing
+is ringed.
## Failure cluster detail