From f40221109cef1648217b20db3c6f3122e6a2543b Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Wed, 5 Aug 2026 13:08:42 +0530 Subject: [PATCH] chore: Fixed tab tests and made the CI pipeline more robust --- playwright.config.ts | 5 +- src/components/WorkflowTabsBar.tsx | 38 +++++++++++-- tests/e2e/studio-mocked/studio-mocked.spec.ts | 53 +++++++++++-------- 3 files changed, 67 insertions(+), 29 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index 21bccab..f57666d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ timeout: 15 * 1000, }, fullyParallel: false, + retries: process.env.CI ? 1 : 0, workers: 1, reporter: [['list'], ['html', { open: 'never' }]], webServer: { @@ -31,8 +32,8 @@ export default defineConfig({ ...devices['Desktop Chrome'], baseURL: `http://127.0.0.1:${mockFrontendPort}`, channel: process.env.PLAYWRIGHT_CHANNEL || undefined, - trace: 'retain-on-failure', + trace: process.env.CI ? 'retain-on-first-failure' : 'retain-on-failure', screenshot: 'only-on-failure', - video: 'retain-on-failure', + video: process.env.CI ? 'retain-on-first-failure' : 'retain-on-failure', }, }); diff --git a/src/components/WorkflowTabsBar.tsx b/src/components/WorkflowTabsBar.tsx index 52d3c2e..6fe01c3 100644 --- a/src/components/WorkflowTabsBar.tsx +++ b/src/components/WorkflowTabsBar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useRef } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; import { Plus, X } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { useFlowStore } from '../stores/useFlowStore'; @@ -10,6 +10,7 @@ import { markWorkflowTabClosed } from '../studio/useWorkflowBackendSync'; export default function WorkflowTabsBar() { const tabListRef = useRef(null); + const pendingTabRevealRef = useRef(null); const pendingSnapshotSaveRef = useRef(false); const { workflowTabs, @@ -37,6 +38,34 @@ export default function WorkflowTabsBar() { const graphBinding = useStudioStore((state) => state.graphBinding); const workflowCanvasHydrated = useStudioStore((state) => state.workflowCanvasHydrated); + const revealTabItem = useCallback((tabItem: HTMLElement) => { + const tabList = tabListRef.current; + if (!tabList) return; + + if (pendingTabRevealRef.current !== null) { + window.cancelAnimationFrame(pendingTabRevealRef.current); + } + + revealHorizontalItem(tabList, tabItem); + pendingTabRevealRef.current = window.requestAnimationFrame(() => { + pendingTabRevealRef.current = null; + if (!tabItem.isConnected || !tabList.contains(tabItem)) return; + // Native focus scrolling can run after the focus event and reveal only + // the tab button. Recheck after layout so the adjacent close action is + // kept inside the horizontal scrollport as well. + revealHorizontalItem(tabList, tabItem); + }); + }, []); + + useEffect( + () => () => { + if (pendingTabRevealRef.current !== null) { + window.cancelAnimationFrame(pendingTabRevealRef.current); + } + }, + [], + ); + useEffect(() => { if (!workflowCanvasHydrated) return; ensureWorkflowTabs(); @@ -66,8 +95,8 @@ export default function WorkflowTabsBar() { useLayoutEffect(() => { const tabList = tabListRef.current; const activeTab = tabList?.querySelector('[aria-selected="true"]')?.parentElement; - if (tabList && activeTab) revealHorizontalItem(tabList, activeTab); - }, [activeWorkflowTabId, workflowTabs.length]); + if (activeTab) revealTabItem(activeTab); + }, [activeWorkflowTabId, revealTabItem, workflowTabs.length]); if (workflowTabs.length === 0 || !activeWorkflowTabId) return null; @@ -108,9 +137,8 @@ export default function WorkflowTabsBar() { data-testid={`workflow-tab-${tab.id}`} onSelect={() => switchWorkflowTab(tab.id)} onFocus={(event) => { - const tabList = event.currentTarget.closest('[data-testid="workflow-tabs-scroll"]'); const tabItem = event.currentTarget.parentElement; - if (tabList && tabItem) revealHorizontalItem(tabList, tabItem); + if (tabItem) revealTabItem(tabItem); }} className="min-w-0 flex-1 justify-start rounded-none px-2 text-left text-xs leading-none focus-visible:outline-inset" > diff --git a/tests/e2e/studio-mocked/studio-mocked.spec.ts b/tests/e2e/studio-mocked/studio-mocked.spec.ts index 9bd28ee..37ea3d6 100644 --- a/tests/e2e/studio-mocked/studio-mocked.spec.ts +++ b/tests/e2e/studio-mocked/studio-mocked.spec.ts @@ -2461,10 +2461,12 @@ test('mocked Studio keeps model health contextual while exposing every authored await expect(page.getByTestId('template-browser-use-flux_fill_inpaint')).toBeVisible(); await page.getByTestId('template-browser-search').fill('canny'); await expect(page.getByTestId('template-browser-use-flux_control_canny')).toBeVisible(); - await page.getByTestId('template-browser-model-filter').click(); + const modelFilter = page.getByTestId('template-browser-model-filter'); + await modelFilter.click(); await expect(page.getByRole('option', { name: 'FLUX.1-dev', exact: true })).toBeVisible(); await expect(page.getByRole('option', { name: 'FLUX.1-Krea-dev', exact: true })).toBeVisible(); - await page.keyboard.press('Escape'); + await modelFilter.click(); + await expect(page.getByRole('listbox')).toBeHidden(); await page.getByTestId('template-browser-search').fill('flux schnell'); await expect(page.getByTestId('template-usage-warning-flux_schnell_text_to_image')).toHaveCount(0); @@ -6427,26 +6429,31 @@ test('previous video and audio renders open in-app without replacing the current const videoStage = page.locator(`.react-flow__node[data-id="${videoState.exporterId}"]`); const emptyVideo = page.getByTestId(`node-preview-empty-${videoState.exporterId}-preview`); + await expect(videoStage).toBeVisible(); await expect(emptyVideo).toBeVisible(); await expect(videoStage.getByText('Previous')).toBeVisible(); await videoStage.getByTitle(/frames from|Video from/).click(); - await expect(page.getByTestId('media-viewer-dialog')).toBeVisible(); - await expect(page.getByTestId('media-viewer-video')).toBeVisible(); + const mediaViewer = page.getByTestId('media-viewer-dialog'); + await expect(mediaViewer).toBeVisible(); + await expect(mediaViewer.getByTestId('media-viewer-video')).toBeVisible(); await expect(emptyVideo).toBeVisible(); - await page.getByRole('button', { name: 'Close media viewer' }).click(); - await expect(page.getByTestId('media-viewer-dialog')).toHaveCount(0); + await mediaViewer.getByRole('button', { name: 'Close media viewer' }).click(); + await expect(mediaViewer).toHaveCount(0); await videoStage.getByTitle(/frames from|Video from/).click(); - await expect(page.getByTestId('media-viewer-dialog')).toBeVisible(); - await page.getByRole('button', { name: 'Download video' }).click(); - await expect(page.getByTestId('media-export-dialog')).toBeVisible(); - await page.getByLabel('Download format').click(); + await expect(mediaViewer).toBeVisible(); + await mediaViewer.getByRole('button', { name: 'Download video' }).click(); + const mediaExport = page.getByTestId('media-export-dialog'); + await expect(mediaExport).toBeVisible(); + await mediaExport.getByLabel('Download format').click(); await expect(page.getByRole('option', { name: 'MP4 (H.264 + AAC)' })).toBeVisible(); await expect(page.getByRole('option', { name: 'WebM (VP9 + Opus)' })).toBeVisible(); await expect(page.getByRole('option', { name: 'MOV (ProRes + PCM)' })).toBeVisible(); await expect(page.getByRole('option', { name: 'Animated GIF' })).toBeVisible(); await page.keyboard.press('Escape'); - await page.getByRole('button', { name: 'Cancel' }).click(); - await page.getByRole('button', { name: 'Close media viewer' }).click(); + await mediaExport.getByRole('button', { name: 'Cancel' }).click(); + await expect(mediaExport).toHaveCount(0); + await mediaViewer.getByRole('button', { name: 'Close media viewer' }).click(); + await expect(mediaViewer).toHaveCount(0); await videoStage.getByRole('button', { name: 'Output actions' }).click(); const copyUrlAction = page.getByRole('menuitem', { name: 'Copy URL' }); @@ -6457,12 +6464,12 @@ test('previous video and audio renders open in-app without replacing the current return url.pathname === '/cache/history/video.mp4' && url.searchParams.get('download_format') === 'webm'; }); await page.getByRole('menuitem', { name: /^Download/ }).click(); - await expect(page.getByTestId('media-export-dialog')).toBeVisible(); - await page.getByLabel('Download format').click(); + await expect(mediaExport).toBeVisible(); + await mediaExport.getByLabel('Download format').click(); await page.getByRole('option', { name: 'WebM (VP9 + Opus)' }).click(); - await page.getByRole('button', { name: 'Download', exact: true }).click(); + await mediaExport.getByRole('button', { name: 'Download', exact: true }).click(); await convertedDownload; - await expect(page.getByTestId('media-export-dialog')).toHaveCount(0); + await expect(mediaExport).toHaveCount(0); await expect(copyUrlAction).toHaveCount(0); await page.evaluate(({ exporterId }) => { @@ -6488,10 +6495,10 @@ test('previous video and audio renders open in-app without replacing the current await expect(videoActions).toHaveClass(/rounded-full/); await videoActions.click(); await page.getByRole('menuitem', { name: /^Download/ }).click(); - await expect(page.getByTestId('media-export-dialog')).toBeVisible(); - await expect(page.getByLabel('Download format')).toBeVisible(); - await page.getByRole('button', { name: 'Cancel' }).click(); - await expect(page.getByTestId('media-export-dialog')).toHaveCount(0); + await expect(mediaExport).toBeVisible(); + await expect(mediaExport.getByLabel('Download format')).toBeVisible(); + await mediaExport.getByRole('button', { name: 'Cancel' }).click(); + await expect(mediaExport).toHaveCount(0); const audioState = await page.evaluate(async () => { await window.__MODIFF_E2E__!.applyTemplate('ace_step_text_to_audio'); @@ -6514,10 +6521,12 @@ test('previous video and audio renders open in-app without replacing the current const audioStage = page.locator(`.react-flow__node[data-id="${audioState.exporterId}"]`); const emptyAudio = page.getByTestId(`node-preview-empty-${audioState.exporterId}-preview`); + await expect(videoStage).toHaveCount(0); + await expect(audioStage).toBeVisible(); await expect(emptyAudio).toBeVisible(); await audioStage.getByTitle(/Audio from/).click(); - await expect(page.getByTestId('media-viewer-dialog')).toBeVisible(); - await expect(page.getByTestId('media-viewer-audio')).toBeVisible(); + await expect(mediaViewer).toBeVisible(); + await expect(mediaViewer.getByTestId('media-viewer-audio')).toBeVisible(); await expect(emptyAudio).toBeVisible(); });