Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/src/test-ui-mode-js.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions packages/playwright/src/isomorphic/testServerConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,10 @@ export class TestServerConnection implements TestServerInterface, TestServerInte
return await this._sendMessage('runTests', params);
}

async acceptSnapshots(params: Parameters<TestServerInterface['acceptSnapshots']>[0]): ReturnType<TestServerInterface['acceptSnapshots']> {
return await this._sendMessage('acceptSnapshots', params);
}

async findRelatedTestFiles(params: Parameters<TestServerInterface['findRelatedTestFiles']>[0]): ReturnType<TestServerInterface['findRelatedTestFiles']> {
return await this._sendMessage('findRelatedTestFiles', params);
}
Expand Down
10 changes: 10 additions & 0 deletions packages/playwright/src/isomorphic/testServerInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]; }>;
Expand Down
27 changes: 27 additions & 0 deletions packages/playwright/src/runner/testServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -135,6 +137,31 @@ export class TestServerDispatcher implements TestServerInterface {

async ping() {}

async acceptSnapshots(params: Parameters<TestServerInterface['acceptSnapshots']>[0]): ReturnType<TestServerInterface['acceptSnapshots']> {
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<TestServerInterface['open']>[0]): ReturnType<TestServerInterface['open']> {
if (isUnderTest())
return;
Expand Down
86 changes: 85 additions & 1 deletion packages/trace-viewer/src/ui/uiModeTestListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TreeItem>;

export const TestListView: React.FC<{
Expand All @@ -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<string>, locations: Iterable<string> }) => void,
acceptSnapshots: (paths: [string, string][]) => void,
runningState?: { testIds: Set<string>, itemSelectedByUser?: boolean, completed?: boolean },
watchAll: boolean,
watchedTreeIds: { value: Set<string> },
Expand All @@ -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<TreeState>({ expandedItems: new Map() });
const [selectedTreeItemId, setSelectedTreeItemId] = React.useState<string | undefined>();
const [collapseAllCount, setCollapseAllCount] = React.useState(requestedCollapseAllCount);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -175,6 +258,7 @@ export const TestListView: React.FC<{
{!!treeItem.duration && treeItem.status !== 'skipped' && <div id={timeId} className='ui-mode-tree-item-time'>{msToString(treeItem.duration)}</div>}
<Toolbar noMinHeight={true} noShadow={true}>
<ToolbarButton icon='play' title='Run' onClick={() => runTreeItem(treeItem)} disabled={!!runningState && !runningState.completed}></ToolbarButton>
{hasSnapshotErrors(treeItem) && <ToolbarButton icon='check' title='Accept snapshots' onClick={() => acceptTreeItemSnapshots(treeItem)}></ToolbarButton>}
<ToolbarButton icon='go-to-file' title='Show source' onClick={onRevealSource} style={(treeItem.kind === 'group' && treeItem.subKind === 'folder') ? { visibility: 'hidden' } : {}}></ToolbarButton>
{!watchAll && <ToolbarButton icon='eye' title='Watch' onClick={() => {
if (watchedTreeIds.value.has(treeItem.id))
Expand Down
21 changes: 17 additions & 4 deletions packages/trace-viewer/src/ui/uiModeView.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This made our UI jump when running tests - status line now shrinks. We can't accept patches that make changes like this. Please consider making smaller incremental non-breaking changes.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was certainly unintended. Could you reopen the PR so that I can correct this or would you prefer separate PRs for the backend and frontend parts?

font-size: 11px;
}

@keyframes slideInDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

.status-line > div {
Expand Down
94 changes: 94 additions & 0 deletions packages/trace-viewer/src/ui/uiModeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>('single-worker', false);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -496,20 +567,43 @@ export const UIModeView: React.FC<{}> = ({
setWatchedTreeIds({ value: new Set() });
setWatchAll(!watchAll);
}}></ToolbarButton>
{collectAllSnapshotPaths().length > 0 && <ToolbarButton icon='check' title='Accept all snapshots' onClick={acceptAllSnapshots}></ToolbarButton>}
<ToolbarButton icon='collapse-all' title='Collapse all' onClick={() => {
setCollapseAllCount(collapseAllCount + 1);
}} />
<ToolbarButton icon='expand-all' title='Expand all' onClick={() => {
setExpandAllCount(expandAllCount + 1);
}} />
</Toolbar>
{statusMessage && <div
className='status-line'
onClick={() => 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}
</div>}
<TestListView
filterText={filterText}
testModel={testModel}
testTree={testTree}
testServerConnection={testServerConnection}
runningState={runningState}
runTests={runTests}
acceptSnapshots={acceptSnapshots}
onItemSelected={setSelectedItem}
watchAll={watchAll}
watchedTreeIds={watchedTreeIds}
Expand Down
Loading