Skip to content

Commit 5dba253

Browse files
authored
Merge pull request #688 from PiwiTests/claude/flake-lab-verified
feat(app): mark verified flake fixes on the flaky list and propose quarantine release
2 parents 1b9110b + 530f26a commit 5dba253

18 files changed

Lines changed: 929 additions & 44 deletions

File tree

‎apps/application/app/components/project/FlakyTestsList.vue‎

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import type { FlakyTest } from '~~/types/api';
2+
import type { FlakyTest, VerifiedFixedFlakyTest } from '~~/types/api';
33
import type { TopFlakeSuspect } from '#shared/handlers/flake-profile';
44
import type { FlakeLabSummary } from '#shared/handlers/flake-lab';
55
import { buildTestRowBadges } from '~/utils/test-row-badges';
@@ -21,7 +21,7 @@ const quarantiningId = ref<number | null>(null);
2121
const runsWindow = ref(50);
2222
const rootCauseFilter = ref<string[]>([]);
2323
24-
const { data: tests, status } = await useFetch(
24+
const { data: flaky, status } = await useFetch(
2525
() => {
2626
const params = new URLSearchParams({ runs: String(runsWindow.value) });
2727
if (props.environment) params.set('environment', props.environment);
@@ -32,9 +32,12 @@ const { data: tests, status } = await useFetch(
3232
lazy: true,
3333
server: false,
3434
watch: [runsWindow, () => props.environment, () => props.branch],
35-
transform: (r: { items: FlakyTest[] }) => r.items,
35+
transform: (r: { items: FlakyTest[]; verifiedFixed?: VerifiedFixedFlakyTest[] }) => r,
3636
},
3737
);
38+
const tests = computed(() => flaky.value?.items ?? null);
39+
/** Tests a verified fix took off the ranking; they return to it at their next retry-pass. */
40+
const verifiedFixed = computed(() => flaky.value?.verifiedFixed ?? []);
3841
// The server renders before the client-only fetch starts (`idle`), so idle reads as loading too.
3942
const loading = computed(() => status.value === 'idle' || status.value === 'pending');
4043
@@ -264,5 +267,41 @@ function flakyBadges(test: FlakyTest) {
264267
<p v-if="!loading && filteredTests.length === 0" class="text-sm text-gray-500 py-4 text-center">
265268
No flaky tests detected in the last {{ runsWindow }} runs.
266269
</p>
270+
271+
<div v-if="verifiedFixed.length" class="mt-4 space-y-1.5" data-testid="flaky-verified-fixed">
272+
<p class="text-xs text-muted inline-flex items-center gap-1">
273+
<UIcon name="i-lucide-badge-check" class="size-3.5 text-success" />
274+
Verified fixed: off the ranking until they retry-pass again
275+
</p>
276+
<ul class="rounded-lg border border-default divide-y divide-default">
277+
<li
278+
v-for="test in verifiedFixed"
279+
:key="test.testCaseId"
280+
class="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2"
281+
data-testid="flaky-verified-row"
282+
>
283+
<NuxtLink
284+
:to="`/test-cases/${test.testCaseId}?tab=flakiness`"
285+
class="min-w-0 text-sm font-medium text-highlighted hover:underline break-words"
286+
>
287+
{{ test.title }}
288+
</NuxtLink>
289+
<span class="min-w-0 text-xs text-muted font-mono truncate">{{ test.filePath }}</span>
290+
<span class="flex-1" />
291+
<UBadge
292+
color="success"
293+
variant="soft"
294+
size="xs"
295+
:title="`Flake Lab verified the fix ${formatRelativeTime(test.verifiedFix.verifiedAt)}`"
296+
data-testid="flaky-verified-badge"
297+
>
298+
Verified fixed{{ test.verifiedFix.commit ? ` on ${test.verifiedFix.commit.slice(0, 7)}` : '' }}
299+
</UBadge>
300+
<span class="text-xs text-muted tabular-nums">
301+
{{ test.retryPassRuns }} retry pass{{ test.retryPassRuns === 1 ? '' : 'es' }} before it
302+
</span>
303+
</li>
304+
</ul>
305+
</div>
267306
</UCard>
268307
</template>

‎apps/application/app/components/project/QuarantineTable.vue‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
*
55
* The streak column is the point of the view: a quarantined test still runs, so
66
* it can earn its way out, and the table says when it has rather than waiting
7-
* to be asked.
7+
* to be asked: after a streak of passes, or at once when a Flake Lab verify
8+
* experiment proved the fix after the quarantine.
89
*/
910
import { buildTestRowBadges } from '~/utils/test-row-badges';
1011
@@ -29,6 +30,8 @@ interface QuarantineEntry {
2930
ageMs: number;
3031
consecutivePasses: number;
3132
releaseProposed: boolean;
33+
releaseReason: 'streak' | 'verified-fix' | null;
34+
verifiedFix: { commit: string | null; verifiedAt: string } | null;
3235
runsSinceQuarantine: number;
3336
}
3437
@@ -138,7 +141,18 @@ async function release(testCaseId: number) {
138141
:project-name="projectName"
139142
>
140143
<template #metrics>
141-
<UBadge v-if="entry.releaseProposed" color="success" variant="soft" size="xs">
144+
<UBadge
145+
v-if="entry.releaseReason === 'verified-fix'"
146+
color="success"
147+
variant="soft"
148+
size="xs"
149+
:title="`Flake Lab verified the fix ${formatRelativeTime(entry.verifiedFix!.verifiedAt)}`"
150+
data-testid="quarantine-verified-fix"
151+
>
152+
Verified fixed{{ entry.verifiedFix?.commit ? ` on ${entry.verifiedFix.commit.slice(0, 7)}` : '' }} —
153+
ready
154+
</UBadge>
155+
<UBadge v-else-if="entry.releaseProposed" color="success" variant="soft" size="xs">
142156
{{ entry.consecutivePasses }} green — ready
143157
</UBadge>
144158
<span v-else-if="entry.runsSinceQuarantine === 0" class="text-xs">not run yet</span>

‎apps/application/app/components/test-case/FlakinessTab.vue‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,26 @@
1313
*/
1414
import type { FlakeProfile, FlakeSuspect } from '#shared/handlers/flake-profile';
1515
import { latestSuspectResults, type FlakeExperimentRecord, type FlakeSuspectResult } from '#shared/flake-lab';
16+
import type { VerifiedFix } from '#shared/handlers/flake-verified';
1617
1718
const props = defineProps<{ testCaseId: number; projectId?: number | null; projectLabel?: string | null }>();
1819
1920
const route = useRoute();
2021
const profile = ref<FlakeProfile | null>(null);
2122
const experiments = ref<FlakeExperimentRecord[]>([]);
23+
/** The verify experiment that marks the test verified fixed, holding or not. */
24+
const verifiedFix = ref<VerifiedFix | null>(null);
2225
const loading = ref(true);
2326
const failed = ref(false);
2427
2528
/** Re-read the experiments, when a lab session run from the desktop app ends. */
2629
async function reloadExperiments() {
2730
try {
28-
const res = await $fetch<{ items: FlakeExperimentRecord[] }>(
31+
const res = await $fetch<{ items: FlakeExperimentRecord[]; verifiedFix?: VerifiedFix | null }>(
2932
`/api/test-cases/${props.testCaseId}/flake-experiments`,
3033
);
3134
experiments.value = res.items;
35+
verifiedFix.value = res.verifiedFix ?? null;
3236
} catch {
3337
// The list stays as it was; the next visit reads it again.
3438
}
@@ -41,11 +45,14 @@ watch(
4145
failed.value = false;
4246
const [p, e] = await Promise.allSettled([
4347
$fetch<FlakeProfile>(`/api/test-cases/${id}/flake-profile`),
44-
$fetch<{ items: FlakeExperimentRecord[] }>(`/api/test-cases/${id}/flake-experiments`),
48+
$fetch<{ items: FlakeExperimentRecord[]; verifiedFix?: VerifiedFix | null }>(
49+
`/api/test-cases/${id}/flake-experiments`,
50+
),
4551
]);
4652
if (p.status === 'fulfilled') profile.value = p.value;
4753
else failed.value = true;
4854
experiments.value = e.status === 'fulfilled' ? e.value.items : [];
55+
verifiedFix.value = e.status === 'fulfilled' ? (e.value.verifiedFix ?? null) : null;
4956
loading.value = false;
5057
},
5158
{ immediate: true },
@@ -232,6 +239,37 @@ function detailLine(s: FlakeSuspect): string | null {
232239
data-shot="flake-experiments"
233240
>
234241
<div class="space-y-3" data-testid="flake-experiments">
242+
<p
243+
v-if="verifiedFix"
244+
class="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm"
245+
data-testid="flake-verified-fix"
246+
:data-holding="verifiedFix.flakedAgainAt ? 'false' : 'true'"
247+
>
248+
<UIcon
249+
:name="verifiedFix.flakedAgainAt ? 'i-lucide-rotate-ccw' : 'i-lucide-badge-check'"
250+
:class="verifiedFix.flakedAgainAt ? 'text-warning' : 'text-success'"
251+
class="size-4 shrink-0"
252+
/>
253+
<template v-if="!verifiedFix.flakedAgainAt">
254+
<span class="font-medium text-highlighted"
255+
>Verified fixed<template v-if="verifiedFix.commit">
256+
on <span class="font-mono">{{ verifiedFix.commit.slice(0, 7) }}</span></template
257+
></span
258+
>
259+
<span class="text-muted"
260+
>· {{ formatRelativeTime(verifiedFix.verifiedAt) }} · off the flaky ranking until it retry-passes
261+
again</span
262+
>
263+
</template>
264+
<template v-else>
265+
<span class="text-highlighted"
266+
>Verified fixed<template v-if="verifiedFix.commit">
267+
on <span class="font-mono">{{ verifiedFix.commit.slice(0, 7) }}</span></template
268+
>, then it retry-passed again {{ formatRelativeTime(verifiedFix.flakedAgainAt) }}</span
269+
>
270+
<span class="text-muted">· back on the flaky ranking</span>
271+
</template>
272+
</p>
235273
<p v-if="!loading && experiments.length === 0" class="text-sm text-highlighted leading-relaxed">
236274
None yet. Run the lab from the project root: it applies each suspect’s condition next to a control, with
237275
retries off, until the failure reproduces.

‎apps/application/app/demo/api/router.ts‎

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ import {
101101
createProject,
102102
getProjectMenu,
103103
deleteProjectData,
104-
getProjectFlakyTests,
104+
getProjectFlakyTestsWithVerified,
105105
getProjectsOverview,
106106
getProjectSpecHealth,
107107
} from '#shared/handlers/projects';
@@ -191,6 +191,7 @@ import {
191191
type FlakeResultsInput,
192192
} from '#shared/handlers/flake-lab';
193193
import { buildExecutionReproduce } from '#shared/handlers/reproduce';
194+
import { getVerifiedFixes } from '#shared/handlers/flake-verified';
194195
import {
195196
getFailureCluster,
196197
getOpenFailureClusters,
@@ -742,16 +743,14 @@ const routes: RouteEntry[] = [
742743
: undefined;
743744
// CODEOWNERS resolution needs an SCM client the browser cannot reach —
744745
// ownership stays annotation-only here (seeded cases carry `piwi:` owners).
745-
return {
746-
items: await getProjectFlakyTests(
747-
await getDemoDb(),
748-
+m[1]!,
749-
runs,
750-
environment,
751-
{ tags, owner, priority },
752-
branch,
753-
),
754-
};
746+
return getProjectFlakyTestsWithVerified(
747+
await getDemoDb(),
748+
+m[1]!,
749+
runs,
750+
environment,
751+
{ tags, owner, priority },
752+
branch,
753+
);
755754
},
756755
},
757756
{
@@ -1275,7 +1274,11 @@ const routes: RouteEntry[] = [
12751274
handler: async (m, _b, q, ctx) => {
12761275
await assertDemoEntityScope(ctx, 'case', +m[1]!);
12771276
const limit = Number(q?.get('limit')) || undefined;
1278-
return { items: await listFlakeExperiments(await getDemoDb(), +m[1]!, { limit }) };
1277+
const db = await getDemoDb();
1278+
return {
1279+
items: await listFlakeExperiments(db, +m[1]!, { limit }),
1280+
verifiedFix: (await getVerifiedFixes(db, [+m[1]!])).get(+m[1]!) ?? null,
1281+
};
12791282
},
12801283
},
12811284
{

‎apps/application/server/api/projects/[id]/flaky-tests.get.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { requireProjectAccess, requireRouteId } from '../../../utils/project-access';
22
import { optionalIntQuery } from '../../../utils/query-params';
33
import { getDatabase } from '../../../database';
4-
import { getProjectFlakyTests } from '#shared/handlers/projects';
4+
import { getProjectFlakyTestsWithVerified } from '#shared/handlers/projects';
55
import { parseTagFilter } from '#shared/utils/tag-filter';
66
import { withResolvedOwners } from '../../../utils/scm/ownership';
77
import { TEST_PRIORITIES } from '@piwitests/core/test-meta';
@@ -11,7 +11,7 @@ defineRouteMeta({
1111
tags: ['Analytics'],
1212
summary: 'Flaky test analysis',
1313
description:
14-
'Analyzes test flakiness across recent runs using retry-pass detection and pass/fail alternation scoring. Pass an environment and/or branch to scope the analysis to runs from that deployment environment or SCM branch.',
14+
'Analyzes test flakiness across recent runs using retry-pass detection and pass/fail alternation scoring. Pass an environment and/or branch to scope the analysis to runs from that deployment environment or SCM branch. A test whose Flake Lab verify experiment held, and that has not retry-passed in a run started since, leaves `items` and is listed in `verifiedFixed` (`{ testCaseId, title, filePath, retryPassRuns, lastFlakeAt, verifiedFix }`) until it retry-passes again.',
1515
parameters: [
1616
{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } },
1717
{ name: 'runs', in: 'query', required: false, schema: { type: 'integer' } },
@@ -63,10 +63,17 @@ export default eventHandler(async (event) => {
6363
const db = await getDatabase();
6464

6565
try {
66-
const rows = await getProjectFlakyTests(db, projectId, runsLimit, environment, filter, branch);
66+
const { items, verifiedFixed } = await getProjectFlakyTestsWithVerified(
67+
db,
68+
projectId,
69+
runsLimit,
70+
environment,
71+
filter,
72+
branch,
73+
);
6774
// Fill in the owner from CODEOWNERS for tests that declare none, so the
6875
// leaderboard can be read per team without anyone annotating a test.
69-
return { items: await withResolvedOwners(db, projectId, rows) };
76+
return { items: await withResolvedOwners(db, projectId, items), verifiedFixed };
7077
} catch (e: any) {
7178
if (e?.message === 'Project not found') {
7279
throw apiError({ statusCode: 404, message: 'Project not found' });

‎apps/application/server/api/test-cases/[id]/flake-experiments.get.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { FLAKE_EXPERIMENTS_MAX, listFlakeExperiments } from '#shared/handlers/flake-lab';
2+
import { getVerifiedFixes } from '#shared/handlers/flake-verified';
23
import { requireResolvedProjectAccess, requireRouteId, resolveCaseProjectId } from '../../../utils/project-access';
34

45
defineRouteMeta({
56
openAPI: {
67
tags: ['Test Cases'],
78
summary: 'Flake-lab experiments of a test case',
8-
description: `The test’s finished \`piwi flake\` and \`piwi flake verify\` experiments, newest first (at most ${FLAKE_EXPERIMENTS_MAX}): kind, verdict (reproduce: \`reproduced\`, \`amplified\`, \`not-reproduced\`; verify: \`verified\`, \`still-fails\`, \`inconclusive\`), the commit it ran and the commit of the failures, source, machine, Playwright project, and each arm with its conditions, runs, matching and other failures, discarded rounds, whether it stopped early, and the p-value and verdict the server computed against the control.`,
9+
description: `The test’s finished \`piwi flake\` and \`piwi flake verify\` experiments, newest first (at most ${FLAKE_EXPERIMENTS_MAX}): kind, verdict (reproduce: \`reproduced\`, \`amplified\`, \`not-reproduced\`; verify: \`verified\`, \`still-fails\`, \`inconclusive\`), the commit it ran and the commit of the failures, source, machine, Playwright project, and each arm with its conditions, runs, matching and other failures, discarded rounds, whether it stopped early, and the p-value and verdict the server computed against the control. \`verifiedFix\` is the verify experiment that marks the test verified fixed (\`{ testCaseId, experimentId, commit, verifiedAt, flakedAgainAt }\`, \`flakedAgainAt\` set once it retry-passed in a run started after \`verifiedAt\`), or null.`,
910
'x-required-roles': ['administrator', 'reporter', 'user'],
1011
parameters: [
1112
{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } },
@@ -18,5 +19,8 @@ export default eventHandler(async (event) => {
1819
const testCaseId = requireRouteId(event, 'id', 'test case ID');
1920
const { db } = await requireResolvedProjectAccess(event, testCaseId, resolveCaseProjectId, 'Test case');
2021
const limit = Number(getQuery(event).limit) || undefined;
21-
return { items: await listFlakeExperiments(db, testCaseId, { limit }) };
22+
return {
23+
items: await listFlakeExperiments(db, testCaseId, { limit }),
24+
verifiedFix: (await getVerifiedFixes(db, [testCaseId])).get(testCaseId) ?? null,
25+
};
2226
});

0 commit comments

Comments
 (0)