diff --git a/packages/trace-viewer/src/ui/settingsView.css b/packages/trace-viewer/src/ui/settingsView.css index cc41911066..3ac8597e35 100644 --- a/packages/trace-viewer/src/ui/settingsView.css +++ b/packages/trace-viewer/src/ui/settingsView.css @@ -16,13 +16,22 @@ .settings-view { flex: none; + margin-top: 4px; } .settings-view .setting label { display: flex; flex-direction: row; align-items: center; - margin: 6px 2px; + margin: 4px 2px; +} + +.settings-view .setting:first-of-type label { + margin-top: 2px; +} + +.settings-view .setting:last-of-type label { + margin-bottom: 2px; } .settings-view .setting input { diff --git a/packages/trace-viewer/src/ui/settingsView.tsx b/packages/trace-viewer/src/ui/settingsView.tsx index 5f803d229f..57c058c248 100644 --- a/packages/trace-viewer/src/ui/settingsView.tsx +++ b/packages/trace-viewer/src/ui/settingsView.tsx @@ -22,13 +22,11 @@ export const SettingsView: React.FunctionComponent<{ settings: Setting[], }> = ({ settings }) => { return
- {settings.map(setting => { - return
+ {settings.map(([value, set, title]) => { + return
; })} diff --git a/packages/trace-viewer/src/ui/uiModeTraceView.tsx b/packages/trace-viewer/src/ui/uiModeTraceView.tsx index 86fd3fbd8c..d26f0c3473 100644 --- a/packages/trace-viewer/src/ui/uiModeTraceView.tsx +++ b/packages/trace-viewer/src/ui/uiModeTraceView.tsx @@ -25,11 +25,13 @@ import type { ContextEntry } from '../entries'; import type { SourceLocation } from './modelUtil'; import { idForAction, MultiTraceModel } from './modelUtil'; import { Workbench } from './workbench'; +import { type Setting } from '@web/uiUtils'; export const TraceView: React.FC<{ + showRouteActionsSetting: Setting, item: { treeItem?: TreeItem, testFile?: SourceLocation, testCase?: reporterTypes.TestCase }, rootDir?: string, -}> = ({ item, rootDir }) => { +}> = ({ showRouteActionsSetting, item, rootDir }) => { const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>(); const [counter, setCounter] = React.useState(0); const pollTimer = React.useRef(null); @@ -87,6 +89,7 @@ export const TraceView: React.FC<{ return .settings-toolbar { + border-top: 1px solid var(--vscode-panel-border); + cursor: pointer; +} + +.ui-mode-sidebar > .settings-view { + margin: 0 0 3px 23px; +} + .ui-mode-sidebar input[type=search] { flex: auto; } diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 3e0ad5c65b..5bff9543aa 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -29,7 +29,7 @@ import { ToolbarButton } from '@web/components/toolbarButton'; import { Toolbar } from '@web/components/toolbar'; import type { XtermDataSource } from '@web/components/xtermWrapper'; import { XtermWrapper } from '@web/components/xtermWrapper'; -import { toggleTheme } from '@web/theme'; +import { useDarkModeSetting } from '@web/theme'; import { settings, useSetting } from '@web/uiUtils'; import { statusEx, TestTree } from '@testIsomorphic/testTree'; import type { TreeItem } from '@testIsomorphic/testTree'; @@ -39,6 +39,7 @@ import type { TestModel } from './uiModeModel'; import { FiltersView } from './uiModeFiltersView'; import { TestListView } from './uiModeTestListView'; import { TraceView } from './uiModeTraceView'; +import { SettingsView } from './settingsView'; let xtermSize = { cols: 80, rows: 24 }; const xtermDataSource: XtermDataSource = { @@ -93,6 +94,37 @@ export const UIModeView: React.FC<{}> = ({ const [isDisconnected, setIsDisconnected] = React.useState(false); const [hasBrowsers, setHasBrowsers] = React.useState(true); const [testServerConnection, setTestServerConnection] = React.useState(); + const [settingsVisible, setSettingsVisible] = React.useState(false); + const [testingOptionsVisible, setTestingOptionsVisible] = React.useState(false); + + const [runWorkers, setRunWorkers] = React.useState(queryParams.workers); + const singleWorkerSetting = React.useMemo(() => { + return [ + runWorkers === '1', + (value: boolean) => { + // When started with `--workers=1`, the setting allows to undo that. + // Otherwise, fallback to the cli `--workers=X` argument. + setRunWorkers(value ? '1' : (queryParams.workers === '1' ? undefined : queryParams.workers)); + }, + 'Single worker', + ] as const; + }, [runWorkers, setRunWorkers]); + + const [runHeaded, setRunHeaded] = React.useState(queryParams.headed); + const showBrowserSetting = React.useMemo(() => [runHeaded, setRunHeaded, 'Show browser'] as const, [runHeaded, setRunHeaded]); + + const [runUpdateSnapshots, setRunUpdateSnapshots] = React.useState(queryParams.updateSnapshots); + const updateSnapshotsSetting = React.useMemo(() => { + return [ + runUpdateSnapshots === 'all', + (value: boolean) => setRunUpdateSnapshots(value ? 'all' : 'missing'), + 'Update snapshots', + ] as const; + }, [runUpdateSnapshots, setRunUpdateSnapshots]); + + const [, , showRouteActionsSetting] = useSetting('show-route-actions', true, 'Show route actions'); + + const darkModeSetting = useDarkModeSetting(); const inputRef = React.useRef(null); @@ -284,11 +316,11 @@ export const UIModeView: React.FC<{}> = ({ grepInvert: queryParams.grepInvert, testIds: [...testIds], projects: [...projectFilters].filter(([_, v]) => v).map(([p]) => p), - workers: queryParams.workers, + workers: runWorkers, timeout: queryParams.timeout, - headed: queryParams.headed, + headed: runHeaded, outputDir: queryParams.outputDir, - updateSnapshots: queryParams.updateSnapshots, + updateSnapshots: runUpdateSnapshots, reporters: queryParams.reporters, trace: 'on', }); @@ -300,7 +332,7 @@ export const UIModeView: React.FC<{}> = ({ setTestModel({ ...testModel }); setRunningState(undefined); }); - }, [projectFilters, runningState, testModel, testServerConnection]); + }, [projectFilters, runningState, testModel, testServerConnection, runWorkers, runHeaded, runUpdateSnapshots]); // Watch implementation. React.useEffect(() => { @@ -403,14 +435,13 @@ export const UIModeView: React.FC<{}> = ({
- +
Playwright logo
Playwright
- toggleTheme()} /> reloadTests()} disabled={isRunningTest || isLoading}> { setIsShowingOutput(!isShowingOutput); }} /> {!hasBrowsers && } @@ -457,6 +488,31 @@ export const UIModeView: React.FC<{}> = ({ requestedCollapseAllCount={collapseAllCount} setFilterText={setFilterText} /> + setTestingOptionsVisible(!testingOptionsVisible)}> + +
Testing Options
+
+ {testingOptionsVisible && } + setSettingsVisible(!settingsVisible)}> + +
Settings
+
+ {settingsVisible && }
; diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index 0a5775bfbc..2755262045 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -36,7 +36,7 @@ import { AttachmentsTab } from './attachmentsTab'; import type { Boundaries } from '../geometry'; import { InspectorTab } from './inspectorTab'; import { ToolbarButton } from '@web/components/toolbarButton'; -import { useSetting, msToString } from '@web/uiUtils'; +import { useSetting, msToString, type Setting } from '@web/uiUtils'; import type { Entry } from '@trace/har'; import './workbench.css'; import { testStatusIcon, testStatusText } from './testUtils'; @@ -53,8 +53,9 @@ export const Workbench: React.FunctionComponent<{ isLive?: boolean, status?: UITestStatus, inert?: boolean, + showRouteActionsSetting?: Setting, openPage?: (url: string, target?: string) => Window | any, -}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, status, inert, openPage }) => { +}> = ({ showRouteActionsSetting, model, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, status, inert, openPage }) => { const [selectedAction, setSelectedActionImpl] = React.useState(undefined); const [revealedStack, setRevealedStack] = React.useState(undefined); const [highlightedAction, setHighlightedAction] = React.useState(); @@ -67,7 +68,11 @@ export const Workbench: React.FunctionComponent<{ const activeAction = model ? highlightedAction || selectedAction : undefined; const [selectedTime, setSelectedTime] = React.useState(); const [sidebarLocation, setSidebarLocation] = useSetting<'bottom' | 'right'>('propertiesSidebarLocation', 'bottom'); - const [showRouteActions, , showRouteActionsSetting] = useSetting('show-route-actions', true, 'Show route actions'); + const [, , showRouteActionsSettingInternal] = useSetting(showRouteActionsSetting ? undefined : 'show-route-actions', true, 'Show route actions'); + + const showSettings = !showRouteActionsSetting; + showRouteActionsSetting ||= showRouteActionsSettingInternal; + const showRouteActions = showRouteActionsSetting[0]; const filteredActions = React.useMemo(() => { return (model?.actions || []).filter(action => showRouteActions || action.class !== 'Route'); @@ -229,6 +234,40 @@ export const Workbench: React.FunctionComponent<{ else if (model && model.wallTime) time = Date.now() - model.wallTime; + const actionsTab: TabbedPaneTabModel = { + id: 'actions', + title: 'Actions', + component:
+ {status &&
+ +
{testStatusText(status)}
+
+
{time ? msToString(time) : ''}
+
} + selectPropertiesTab('console')} + isLive={isLive} + /> +
+ }; + const metadataTab: TabbedPaneTabModel = { + id: 'metadata', + title: 'Metadata', + component: + }; + const settingsTab: TabbedPaneTabModel = { + id: 'settings', + title: 'Settings', + component: , + }; + return
- {status &&
- -
{testStatusText(status)}
-
-
{time ? msToString(time) : ''}
-
} - selectPropertiesTab('console')} - isLive={isLive} - /> -
- }, - { - id: 'metadata', - title: 'Metadata', - component: - }, - { - id: 'settings', - title: 'Settings', - component: , - } - ]} + tabs={showSettings ? [actionsTab, metadataTab, settingsTab] : [actionsTab, metadataTab]} selectedTab={selectedNavigatorTab} setSelectedTab={setSelectedNavigatorTab} /> diff --git a/packages/web/src/components/toolbar.tsx b/packages/web/src/components/toolbar.tsx index 5e11de75f7..023fad0232 100644 --- a/packages/web/src/components/toolbar.tsx +++ b/packages/web/src/components/toolbar.tsx @@ -20,12 +20,16 @@ import * as React from 'react'; type ToolbarProps = { noShadow?: boolean; noMinHeight?: boolean; + className?: string; + onClick?: (e: React.MouseEvent) => void; }; export const Toolbar: React.FC> = ({ noShadow, children, - noMinHeight + noMinHeight, + className, + onClick, }) => { - return
{children}
; + return
{children}
; }; diff --git a/packages/web/src/theme.ts b/packages/web/src/theme.ts index 44f663f9e6..5ea2f65566 100644 --- a/packages/web/src/theme.ts +++ b/packages/web/src/theme.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import { settings } from './uiUtils'; +import React from 'react'; +import { type Setting, settings } from './uiUtils'; export function applyTheme() { if ((document as any).playwrightThemeInitialized) @@ -64,3 +65,13 @@ export function removeThemeListener(listener: (theme: Theme) => void) { export function currentTheme(): Theme { return document.body.classList.contains('dark-mode') ? 'dark-mode' : 'light-mode'; } + +export function useDarkModeSetting() { + const [theme, setTheme] = React.useState(currentTheme() === 'dark-mode'); + return [theme, (value: boolean) => { + const current = currentTheme() === 'dark-mode'; + if (current !== value) + toggleTheme(); + setTheme(value); + }, 'Dark mode'] as Setting; +} diff --git a/packages/web/src/uiUtils.ts b/packages/web/src/uiUtils.ts index de28a807c1..9901a24bde 100644 --- a/packages/web/src/uiUtils.ts +++ b/packages/web/src/uiUtils.ts @@ -139,12 +139,7 @@ export function copy(text: string) { textArea.remove(); } -export type Setting = { - value: T; - set: (value: T) => void; - name: string; - title: string; -}; +export type Setting = readonly [T, (value: T) => void, string]; export function useSetting(name: string | undefined, defaultValue: S, title?: string): [S, React.Dispatch>, Setting] { if (name) @@ -155,12 +150,7 @@ export function useSetting(name: string | undefined, defaultValue: S, title?: settings.setObject(name, value); setValue(value); }, [name, setValue]); - const setting = { - value, - set: setValueWrapper, - name: name || '', - title: title || name || '', - }; + const setting = [value, setValueWrapper, title || name || ''] as Setting; return [value, setValueWrapper, setting]; } diff --git a/tests/playwright-test/ui-mode-test-output.spec.ts b/tests/playwright-test/ui-mode-test-output.spec.ts index b8a6cfddd1..b10c02d08a 100644 --- a/tests/playwright-test/ui-mode-test-output.spec.ts +++ b/tests/playwright-test/ui-mode-test-output.spec.ts @@ -45,7 +45,8 @@ test('should work after theme switch', async ({ runUITest, writeFiles }) => { await page.getByTitle('Run all').click(); await expect(page.getByTestId('output')).toContainText(`Hello world 1`); - await page.getByTitle('Toggle color mode').click(); + await page.getByText('Settings', { exact: true }).click(); + await page.getByLabel('Dark mode').click(); await writeFiles({ 'a.test.ts': ` import { test, expect } from '@playwright/test';