From c8f1c005c9d7a31c9da955a98ac76aec524f58eb Mon Sep 17 00:00:00 2001 From: Nikolai Prokoschenko Date: Mon, 24 Nov 2025 16:25:42 +0100 Subject: [PATCH] feat(ui-mode): add snapshot acceptance buttons and workflow Add ability to accept snapshot updates directly from UI mode: - Add "Accept snapshots" button per test/group when snapshot errors are present - Add "Accept all snapshots" button in toolbar to accept all snapshot errors at once - Implement acceptSnapshots API in test server to copy actual to expected snapshots - Support both screenshot snapshots (via attachments) and text snapshots (via matcherResult) - Show compact toast notification with slide-in animation and click-to-dismiss - Execute snapshot acceptance immediately (not queued) to work while tests are running - Hide buttons when no snapshot errors present instead of showing disabled state - Add comprehensive test coverage for the new API This allows developers to quickly update snapshots from failed tests without switching to the command line, improving the UI mode workflow. --- docs/src/test-ui-mode-js.md | 4 + .../src/isomorphic/testServerConnection.ts | 4 + .../src/isomorphic/testServerInterface.ts | 10 ++ packages/playwright/src/runner/testServer.ts | 27 +++++ .../src/ui/uiModeTestListView.tsx | 86 +++++++++++++- packages/trace-viewer/src/ui/uiModeView.css | 21 +++- packages/trace-viewer/src/ui/uiModeView.tsx | 94 +++++++++++++++ tests/playwright-test/test-server.spec.ts | 46 +++++++- .../ui-mode-test-snapshots.spec.ts | 107 ++++++++++++++++++ 9 files changed, 393 insertions(+), 6 deletions(-) create mode 100644 tests/playwright-test/ui-mode-test-snapshots.spec.ts diff --git a/docs/src/test-ui-mode-js.md b/docs/src/test-ui-mode-js.md index 4aaeae92d63ef..170c27125272f 100644 --- a/docs/src/test-ui-mode-js.md +++ b/docs/src/test-ui-mode-js.md @@ -99,6 +99,10 @@ The "Attachments" tab allows you to explore attachments. If you're doing [visual ![ui mode with attachments](https://github.com/microsoft/playwright/assets/13063165/bb83b406-84ed-4380-a96c-0e62d1388093) +### Accepting snapshots + +When a test has snapshot failures, you can accept the new snapshots directly from UI mode. Click the "Accept Snapshots" button that appears when a test has snapshot errors. This will copy the actual snapshots to replace the expected snapshots, similar to running tests with the `--update-snapshots` flag. A status message will confirm how many snapshots were accepted. + ## Metadata Next to the Actions tab you will find the Metadata tab which will show you more information on your test such as the Browser, viewport size, test duration and more. diff --git a/packages/playwright/src/isomorphic/testServerConnection.ts b/packages/playwright/src/isomorphic/testServerConnection.ts index aa4313553ecaf..2e741e7dcba40 100644 --- a/packages/playwright/src/isomorphic/testServerConnection.ts +++ b/packages/playwright/src/isomorphic/testServerConnection.ts @@ -240,6 +240,10 @@ export class TestServerConnection implements TestServerInterface, TestServerInte return await this._sendMessage('runTests', params); } + async acceptSnapshots(params: Parameters[0]): ReturnType { + return await this._sendMessage('acceptSnapshots', params); + } + async findRelatedTestFiles(params: Parameters[0]): ReturnType { return await this._sendMessage('findRelatedTestFiles', params); } diff --git a/packages/playwright/src/isomorphic/testServerInterface.ts b/packages/playwright/src/isomorphic/testServerInterface.ts index fcc34bb0c96ee..272d6edf8ee8f 100644 --- a/packages/playwright/src/isomorphic/testServerInterface.ts +++ b/packages/playwright/src/isomorphic/testServerInterface.ts @@ -109,6 +109,16 @@ export interface TestServerInterface { status: reporterTypes.FullResult['status']; }>; + /** + * Accepts snapshot updates by copying actual snapshots to expected locations. + */ + acceptSnapshots(param: { paths: [string, string][]}): Promise<{ + status: boolean; + accepted: number; + failed: number; + errors?: string[]; + }>; + findRelatedTestFiles(params: { files: string[]; }): Promise<{ testFiles: string[]; errors?: reporterTypes.TestError[]; }>; diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index c1fd0693ab07d..388ad28fa4e37 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -15,6 +15,8 @@ */ import util from 'util'; +import fs from 'fs'; +import path from 'path'; import { installRootRedirect, openTraceInBrowser, openTraceViewerApp, startTraceViewerServer } from 'playwright-core/lib/server'; import { ManualPromise, gracefullyProcessExitDoNotHang, isUnderTest } from 'playwright-core/lib/utils'; @@ -135,6 +137,31 @@ export class TestServerDispatcher implements TestServerInterface { async ping() {} + async acceptSnapshots(params: Parameters[0]): ReturnType { + let accepted = 0; + let failed = 0; + const errors: string[] = []; + + for (const [source, dest] of params.paths) { + try { + // Ensure destination directory exists before copying + await fs.promises.mkdir(path.dirname(dest), { recursive: true }); + await fs.promises.copyFile(source, dest); + accepted++; + } catch (e) { + failed++; + errors.push(`Failed to copy ${source} to ${dest}: ${(e as Error).message}`); + } + } + + return { + status: failed === 0, + accepted, + failed, + errors: errors.length > 0 ? errors : undefined, + }; + } + async open(params: Parameters[0]): ReturnType { if (isUnderTest()) return; diff --git a/packages/trace-viewer/src/ui/uiModeTestListView.tsx b/packages/trace-viewer/src/ui/uiModeTestListView.tsx index 0c900b0f3cc7e..ea33746eee717 100644 --- a/packages/trace-viewer/src/ui/uiModeTestListView.tsx +++ b/packages/trace-viewer/src/ui/uiModeTestListView.tsx @@ -32,6 +32,14 @@ import type { TestServerConnection } from '@testIsomorphic/testServerConnection' import { TagView } from './tag'; import type { TeleSuiteUpdaterTestModel } from '@testIsomorphic/teleSuiteUpdater'; +// Internal type for expect errors with matcher results (not in public API) +type ExpectError = Error & { + matcherResult?: { + actual?: unknown; + expected?: unknown; + }; +}; + const TestTreeView = TreeView; export const TestListView: React.FC<{ @@ -40,6 +48,7 @@ export const TestListView: React.FC<{ testServerConnection: TestServerConnection | undefined, testModel?: TeleSuiteUpdaterTestModel, runTests: (mode: 'bounce-if-busy' | 'queue-if-busy', filter: { testIds: Iterable, locations: Iterable }) => void, + acceptSnapshots: (paths: [string, string][]) => void, runningState?: { testIds: Set, itemSelectedByUser?: boolean, completed?: boolean }, watchAll: boolean, watchedTreeIds: { value: Set }, @@ -50,7 +59,7 @@ export const TestListView: React.FC<{ requestedExpandAllCount: number, setFilterText: (text: string) => void, onRevealSource: () => void, -}> = ({ filterText, testModel, testServerConnection, testTree, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, requestedCollapseAllCount, requestedExpandAllCount, setFilterText, onRevealSource }) => { +}> = ({ filterText, testModel, testServerConnection, testTree, runTests, acceptSnapshots, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, requestedCollapseAllCount, requestedExpandAllCount, setFilterText, onRevealSource }) => { const [treeState, setTreeState] = React.useState({ expandedItems: new Map() }); const [selectedTreeItemId, setSelectedTreeItemId] = React.useState(); const [collapseAllCount, setCollapseAllCount] = React.useState(requestedCollapseAllCount); @@ -142,6 +151,80 @@ export const TestListView: React.FC<{ runTests('bounce-if-busy', testTree.collectTestIds(treeItem)); }; + const hasSnapshotErrors = React.useCallback((treeItem: TreeItem): boolean => { + if (!testModel) + return false; + + const filter = testTree.collectTestIds(treeItem); + const testIdSet = new Set(filter.testIds); + for (const test of testModel.rootSuite.allTests()) { + if (testIdSet.has(test.id)) { + for (const result of test.results) { + if (result.errors && result.errors.length > 0) { + // Check for screenshot/image snapshot failures (stored as attachments) + const hasExpected = result.attachments.some(a => a.name.includes('-expected.')); + const hasActual = result.attachments.some(a => a.name.includes('-actual.')); + if (hasExpected && hasActual) + return true; + + // Check for text snapshot failures (stored in matcherResult) + for (const error of result.errors) { + const expectError = error as ExpectError; + if (expectError?.matcherResult) { + const matcherResult = expectError.matcherResult; + if (typeof matcherResult.actual === 'string' && typeof matcherResult.expected === 'string') + return true; + } + } + } + } + } + } + return false; + }, [testModel, testTree]); + + const acceptTreeItemSnapshots = (treeItem: TreeItem) => { + setSelectedTreeItemId(treeItem.id); + + const updates: [string, string][] = []; + + const filter = testTree.collectTestIds(treeItem); + const testIdSet = new Set(filter.testIds); + for (const test of testModel!.rootSuite.allTests()) { + if (testIdSet.has(test.id)) { + for (const result of test.results) { + if (result.errors && result.errors.length > 0) { + // Collect paths from attachments (works for both screenshot and text snapshots) + for (const attachment of result.attachments) { + if (attachment.name.includes('-actual.') && attachment.path) { + // Find the corresponding -expected attachment + const baseName = attachment.name.replace('-actual.', '-expected.'); + const expectedAttachment = result.attachments.find(a => a.name === baseName); + if (expectedAttachment?.path) { + updates.push([attachment.path, expectedAttachment.path]); + } + } + } + + // Also check matcherResult for backwards compatibility + for (const error of result.errors) { + const expectError = error as ExpectError; + if (expectError?.matcherResult) { + const matcherResult = expectError.matcherResult; + if (typeof matcherResult.actual === 'string' && typeof matcherResult.expected === 'string') + updates.push([matcherResult.actual, matcherResult.expected]); + } + } + } + } + } + } + + // Only call API if we have snapshots to accept + if (updates.length > 0) + acceptSnapshots(updates); + }; + const handleTagClick = (e: React.MouseEvent, tag: string) => { e.preventDefault(); e.stopPropagation(); @@ -175,6 +258,7 @@ export const TestListView: React.FC<{ {!!treeItem.duration && treeItem.status !== 'skipped' &&
{msToString(treeItem.duration)}
} runTreeItem(treeItem)} disabled={!!runningState && !runningState.completed}> + {hasSnapshotErrors(treeItem) && acceptTreeItemSnapshots(treeItem)}>} {!watchAll && { if (watchedTreeIds.value.has(treeItem.id)) diff --git a/packages/trace-viewer/src/ui/uiModeView.css b/packages/trace-viewer/src/ui/uiModeView.css index 3674e16e20c68..df26ea73279ef 100644 --- a/packages/trace-viewer/src/ui/uiModeView.css +++ b/packages/trace-viewer/src/ui/uiModeView.css @@ -83,14 +83,27 @@ } .status-line { - flex: auto; + flex: none; white-space: nowrap; - line-height: 22px; - padding-left: 10px; + line-height: 16px; + padding: 4px 8px; display: flex; flex-direction: row; align-items: center; - height: 30px; + height: auto; + min-height: 20px; + font-size: 11px; +} + +@keyframes slideInDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } } .status-line > div { diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 227c0ef332a47..d96a58a5bc788 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -105,6 +105,7 @@ export const UIModeView: React.FC<{}> = ({ const [settingsVisible, setSettingsVisible] = React.useState(false); const [testingOptionsVisible, setTestingOptionsVisible] = React.useState(false); const [revealSource, setRevealSource] = React.useState(false); + const [statusMessage, setStatusMessage] = React.useState<{ text: string, type: 'info' | 'error' } | undefined>(); const onRevealSource = React.useCallback(() => setRevealSource(true), [setRevealSource]); const [singleWorker, setSingleWorker] = useSetting('single-worker', false); @@ -313,6 +314,76 @@ export const UIModeView: React.FC<{}> = ({ const runVisibleTests = React.useCallback(() => runTests('bounce-if-busy', testTree.collectTestIds(testTree.rootItem)), [runTests, testTree]); + const acceptSnapshots = React.useCallback(async (paths: [string, string][]) => { + + if (!testServerConnection || !testModel) + return; + + // Show immediate feedback + setStatusMessage({ text: `Accepting ${paths.length} snapshot(s)...`, type: 'info' }); + + // Don't use commandQueue - execute immediately to allow accepting snapshots while tests run + try { + const result = await testServerConnection.acceptSnapshots({ + paths, + }); + + if (result.failed > 0) + setStatusMessage({ text: `Accepted ${result.accepted} snapshot(s), failed: ${result.failed}. Check console for details.`, type: 'error' }); + else + setStatusMessage({ text: `Accepted ${result.accepted} snapshot(s)`, type: 'info' }); + + // Log errors for debugging + if (result.errors?.length) { + // eslint-disable-next-line no-console + result.errors.forEach(err => console.error(err)); + } + } catch (error) { + setStatusMessage({ text: `Failed to accept snapshots: ${error}`, type: 'error' }); + // eslint-disable-next-line no-console + console.error('Accept snapshots error:', error); + } + }, [testModel, testServerConnection]); + + const collectAllSnapshotPaths = React.useCallback((): [string, string][] => { + if (!testModel) + return []; + + const updates: [string, string][] = []; + for (const test of testModel.rootSuite.allTests()) { + for (const result of test.results) { + if (result.errors && result.errors.length > 0) { + // Collect paths from attachments + for (const attachment of result.attachments) { + if (attachment.name.includes('-actual.') && attachment.path) { + const baseName = attachment.name.replace('-actual.', '-expected.'); + const expectedAttachment = result.attachments.find(a => a.name === baseName); + if (expectedAttachment?.path) { + updates.push([attachment.path, expectedAttachment.path]); + } + } + } + } + } + } + return updates; + }, [testModel]); + + const acceptAllSnapshots = React.useCallback(async () => { + const paths = collectAllSnapshotPaths(); + if (paths.length === 0) + return; + await acceptSnapshots(paths); + }, [collectAllSnapshotPaths, acceptSnapshots]); + + // Auto-clear status message after 3 seconds + React.useEffect(() => { + if (!statusMessage) + return; + const timeout = setTimeout(() => setStatusMessage(undefined), 3000); + return () => clearTimeout(timeout); + }, [statusMessage]); + React.useEffect(() => { if (!testServerConnection || !teleSuiteUpdater) return; @@ -496,6 +567,7 @@ export const UIModeView: React.FC<{}> = ({ setWatchedTreeIds({ value: new Set() }); setWatchAll(!watchAll); }}> + {collectAllSnapshotPaths().length > 0 && } { setCollapseAllCount(collapseAllCount + 1); }} /> @@ -503,6 +575,27 @@ export const UIModeView: React.FC<{}> = ({ setExpandAllCount(expandAllCount + 1); }} /> + {statusMessage &&
setStatusMessage(undefined)} + style={{ + position: 'absolute', + top: '10px', + left: '10px', + zIndex: 1000, + padding: '6px 10px', + fontSize: '11px', + backgroundColor: statusMessage.type === 'error' ? 'var(--vscode-inputValidation-errorBackground)' : 'var(--vscode-inputValidation-infoBackground)', + color: statusMessage.type === 'error' ? 'var(--vscode-inputValidation-errorForeground)' : 'var(--vscode-inputValidation-infoForeground)', + border: statusMessage.type === 'error' ? '1px solid var(--vscode-inputValidation-errorBorder)' : '1px solid var(--vscode-inputValidation-infoBorder)', + borderRadius: '3px', + boxShadow: '0 2px 6px rgba(0, 0, 0, 0.15)', + cursor: 'pointer', + animation: 'slideInDown 0.2s ease-out', + maxWidth: '300px', + }}> + {statusMessage.text} +
} = ({ testServerConnection={testServerConnection} runningState={runningState} runTests={runTests} + acceptSnapshots={acceptSnapshots} onItemSelected={setSelectedItem} watchAll={watchAll} watchedTreeIds={watchedTreeIds} diff --git a/tests/playwright-test/test-server.spec.ts b/tests/playwright-test/test-server.spec.ts index a83ca3910a86b..fc54e606ffcad 100644 --- a/tests/playwright-test/test-server.spec.ts +++ b/tests/playwright-test/test-server.spec.ts @@ -17,8 +17,10 @@ import { test as baseTest, expect } from './ui-mode-fixtures'; import { TestServerConnection } from '../../packages/playwright/lib/isomorphic/testServerConnection'; import { playwrightCtConfigText } from './playwright-test-fixtures'; -import ws from 'ws'; import type { TestChildProcess } from '../config/commonFixtures'; +import ws from 'ws'; +import fs from 'fs'; +import path from 'path'; class WSTransport { private _ws: ws.WebSocket; @@ -328,3 +330,45 @@ test('pauseOnError no errors', async ({ startTestServer, writeFiles }) => { expect(await testServerConnection.runTests({ pauseOnError: true, locations: [] })).toEqual({ status: 'passed' }); expect(testServerConnection.events.filter(e => e[0] === 'testPaused')).toEqual([]); }); + +test('should accept snapshots via test server API', async ({ startTestServer, writeFiles }, testInfo) => { + await writeFiles({ + 'snapshot.test.ts': ` + import { test, expect } from '@playwright/test'; + test('text snapshot', () => { + expect('actual content').toMatchSnapshot('snapshot.txt'); + }); + `, + }); + + const testServerConnection = await startTestServer(); + + // Run the test - will fail with missing snapshot + await testServerConnection.runTests({}); + + // Create a fake "actual" file to accept + const testDir = testInfo.outputPath(); + const actualFile = path.join(testDir, 'test-results', 'snapshot-text-snapshot-chromium', 'snapshot-actual.txt'); + const expectedFile = path.join(testDir, 'snapshot.test.ts-snapshots', 'snapshot.txt'); + + fs.mkdirSync(path.dirname(actualFile), { recursive: true }); + fs.writeFileSync(actualFile, 'actual content'); + + // Accept the snapshot + const result = await testServerConnection.acceptSnapshots({ + paths: [[actualFile, expectedFile]] + }); + + // Verify the result + expect(result.status).toBe(true); + expect(result.accepted).toBe(1); + expect(result.failed).toBe(0); + + // Verify the file was copied + expect(fs.existsSync(expectedFile)).toBeTruthy(); + expect(fs.readFileSync(expectedFile, 'utf-8')).toBe('actual content'); + + // Re-run the test - should pass now + const testResult = await testServerConnection.runTests({}); + expect(testResult.status).toBe('passed'); +}); diff --git a/tests/playwright-test/ui-mode-test-snapshots.spec.ts b/tests/playwright-test/ui-mode-test-snapshots.spec.ts new file mode 100644 index 0000000000000..2a4e6ff5b6bab --- /dev/null +++ b/tests/playwright-test/ui-mode-test-snapshots.spec.ts @@ -0,0 +1,107 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect, retries } from './ui-mode-fixtures'; + +test.describe.configure({ mode: 'parallel', retries }); + +test('should show accept snapshots button when snapshot fails', async ({ runUITest }, testInfo) => { + const { page } = await runUITest({ + 'snapshot.test.ts': ` + import { test, expect } from '@playwright/test'; + test('snapshot test', async ({ page }) => { + await page.setContent('
Hello World
'); + await expect(page.locator('div')).toHaveScreenshot('snapshot.png'); + }); + `, + }); + + // Run the test - will fail on first run (missing snapshot) + await page.getByTitle('Run all').click(); + + // Wait for test to complete + await expect(page.getByTestId('status-line')).toContainText('/1'); + + // Accept snapshots button should be visible + const testItem = page.locator('.ui-mode-tree-item:has-text("snapshot test")'); + const acceptButton = testItem.getByTitle('Accept snapshots'); + await expect(acceptButton).toBeVisible(); +}); + +test('should hide accept snapshots button when no snapshot errors', async ({ runUITest }, testInfo) => { + const { page } = await runUITest({ + 'passing.test.ts': ` + import { test, expect } from '@playwright/test'; + test('passing test', async () => { + expect(1 + 1).toBe(2); + }); + `, + }); + + // Run the test + await page.getByTitle('Run all').click(); + + // Wait for test to complete + await expect(page.getByTestId('status-line')).toContainText('/1'); + + // Accept snapshots button should not be visible + const testItem = page.locator('.ui-mode-tree-item:has-text("passing test")'); + const acceptButton = testItem.getByTitle('Accept snapshots'); + await expect(acceptButton).toBeHidden(); +}); + +test('should show accept all snapshots button in toolbar when snapshots fail', async ({ runUITest }, testInfo) => { + const { page } = await runUITest({ + 'snapshot.test.ts': ` + import { test, expect } from '@playwright/test'; + test('snapshot test', async ({ page }) => { + await page.setContent('
Hello World
'); + await expect(page.locator('div')).toHaveScreenshot('snapshot.png'); + }); + `, + }); + + // Run the test - will fail on first run (missing snapshot) + await page.getByTitle('Run all').click(); + + // Wait for test to complete + await expect(page.getByTestId('status-line')).toContainText('/1'); + + // Accept all snapshots button should be visible in toolbar + const acceptAllButton = page.getByTitle('Accept all snapshots'); + await expect(acceptAllButton).toBeVisible(); +}); + +test('should hide accept all snapshots button when no snapshot errors', async ({ runUITest }, testInfo) => { + const { page } = await runUITest({ + 'passing.test.ts': ` + import { test, expect } from '@playwright/test'; + test('passing test', async () => { + expect(1 + 1).toBe(2); + }); + `, + }); + + // Run the test + await page.getByTitle('Run all').click(); + + // Wait for test to complete + await expect(page.getByTestId('status-line')).toContainText('/1'); + + // Accept all snapshots button should not be visible in toolbar + const acceptAllButton = page.getByTitle('Accept all snapshots'); + await expect(acceptAllButton).toBeHidden(); +});