From 4fe413a2043f97f48558c6c48edcd5af4a412a35 Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 3 Oct 2026 19:11:05 +0900 Subject: [PATCH] fix(publish): keep the failing test of a pytest run in a draft PR's Last failure (AGT-4672) summarizeFailure kept the first 500 characters. For a pytest failure those are progress dots, so drafts #796 and #798 said nothing about what failed; the failing test ids and totals are at the end. Drop progress runs and percentage marks first, then keep the head and the tail of what is left. --- src/automation/publishOnPark.test.ts | 37 +++++++++++++++++++++++++++- src/automation/publishOnPark.ts | 21 +++++++++++++--- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/automation/publishOnPark.test.ts b/src/automation/publishOnPark.test.ts index b1ddcdc2..a4cd7607 100644 --- a/src/automation/publishOnPark.test.ts +++ b/src/automation/publishOnPark.test.ts @@ -6,7 +6,7 @@ vi.mock('../support/worktreeManager.js', () => ({ commitAndCreatePRWithHead })); vi.mock('../core/eventHub.js', () => ({ broadcastEvent: vi.fn() })); import { PublicationScopeMismatchError } from '../support/publicationScopeFence.js'; -import { PUBLICATION_SCOPE_PARK_REASON, WORKER_NO_CHANGES_PARK_REASON, publishApprovedWork, publishFinishedRun, publishParkedIfNeeded, publishParkedWork, publishStuckWork, publishUnfinishedWork, shouldPublishParkedWork, shouldPublishUnfinishedWork } from './publishOnPark.js'; +import { PUBLICATION_SCOPE_PARK_REASON, WORKER_NO_CHANGES_PARK_REASON, publishApprovedWork, publishFinishedRun, publishParkedIfNeeded, publishParkedWork, publishStuckWork, publishUnfinishedWork, shouldPublishParkedWork, shouldPublishUnfinishedWork, summarizeFailure } from './publishOnPark.js'; beforeEach(() => { commitAndCreatePRWithHead.mockReset(); @@ -48,6 +48,41 @@ describe('shouldPublishParkedWork (AGT-4076)', () => { }); }); +describe('summarizeFailure (AGT-4672)', () => { + const dots = '.'.repeat(71); + const pytest = [ + `[pytest:apps/pipelines] ${dots} [ 10%]`, + `${dots.slice(0, 40)}ss${dots.slice(0, 29)} [ 11%]`, + `${'s'.repeat(30)}${dots.slice(0, 41)} [ 12%]`, + '=================================== FAILURES ===================================', + '___________________________ test_month_column_width ____________________________', + 'FAILED apps/pipelines/tests/test_b1_store_names.py::test_alias_rows - AssertionError: 4 != 8', + '1 failed, 412 passed, 31 skipped in 212.40s', + ].join('\n'); + + it('keeps the failing test and the totals of a long pytest run', () => { + const summary = summarizeFailure(pytest); + expect(summary).toContain('FAILED apps/pipelines/tests/test_b1_store_names.py::test_alias_rows'); + expect(summary).toContain('1 failed, 412 passed'); + expect(summary).not.toMatch(/\.{8,}/); + expect(summary.length).toBeLessThanOrEqual(500 + 5); + }); + + it('keeps the head and the tail of a long detail that has no progress rows', () => { + const detail = `worker: ${'a'.repeat(900)} end-marker`; + const summary = summarizeFailure(detail); + expect(summary.startsWith('worker: aaa')).toBe(true); + expect(summary.endsWith('end-marker')).toBe(true); + expect(summary).toContain(' … '); + }); + + it('leaves a short detail alone and names an empty one', () => { + expect(summarizeFailure('reviewer: tests missing for the new branch')).toBe('reviewer: tests missing for the new branch'); + expect(summarizeFailure(undefined)).toBe('no failure detail was recorded'); + expect(summarizeFailure(' \n ')).toBe('no failure detail was recorded'); + }); +}); + describe('publication identity (AGT-4145)', () => { it('durably attaches the exact head returned by a successful reviewed publication', async () => { commitAndCreatePRWithHead.mockResolvedValue({ diff --git a/src/automation/publishOnPark.ts b/src/automation/publishOnPark.ts index 841a163f..f717402c 100644 --- a/src/automation/publishOnPark.ts +++ b/src/automation/publishOnPark.ts @@ -459,11 +459,26 @@ export function shouldPublishUnfinishedWork( return UNFINISHED_STATUSES.has(result.finalStatus ?? ''); } +// A pytest progress row is only result marks and a percentage. The first 500 +// characters of a failing run were all of them, so a draft PR said nothing about +// what failed (AGT-4672: #796, #798); the failing ids and totals come last. +const PROGRESS_RUN = /(?:^|\s)[.sxXFE]{8,}(?=\s|$)/g; +const PERCENT_MARK = /\s\[\s*\d{1,3}%\]/g; +const SUMMARY_CAP = 500; +const SUMMARY_HEAD = 150; + /** One line of a failure detail, bounded, for the draft PR's body. */ -function summarizeFailure(detail: string | undefined): string { - const flat = (detail ?? '').replace(/\s+/g, ' ').trim(); +export function summarizeFailure(detail: string | undefined): string { + const flat = (detail ?? '') + .replace(/\s+/g, ' ') + .replace(PROGRESS_RUN, ' ') + .replace(PERCENT_MARK, '') + .replace(/\s+/g, ' ') + .trim(); if (!flat) return 'no failure detail was recorded'; - return flat.length > 500 ? `${flat.slice(0, 500)}…` : flat; + if (flat.length <= SUMMARY_CAP) return flat; + // Keep both ends: the stage that failed leads, the final failure lines trail. + return `${flat.slice(0, SUMMARY_HEAD)} … ${flat.slice(-(SUMMARY_CAP - SUMMARY_HEAD))}`; } /**