chore(ui): ui polish / nits (#21781)

This commit is contained in:
Pavel Feldman 2023-03-19 12:04:19 -07:00 committed by GitHub
parent 0728d0f7fb
commit 8a65cf9aac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 234 additions and 108 deletions

View File

@ -41,6 +41,9 @@ export async function syncLocalStorageWithSettings(page: Page, appName: string)
const settings = await fs.promises.readFile(settingsFile, 'utf-8').catch(() => ('{}'));
await page.addInitScript(
`(${String((settings: any) => {
// iframes w/ snapshots, etc.
if (location && location.protocol === 'data:')
return;
Object.entries(settings).map(([k, v]) => localStorage[k] = v);
(window as any).saveSettings = () => {
(window as any)._saveSerializedSettings(JSON.stringify({ ...localStorage }));

View File

@ -42,9 +42,6 @@ async function loadTrace(traceUrl: string, traceFileName: string | null, clientI
try {
await traceModel.load(traceUrl, progress);
} catch (error: any) {
// eslint-disable-next-line no-console
console.error(error);
if (error?.message?.includes('Cannot find .trace file') && await traceModel.hasEntry('index.html'))
throw new Error('Could not load trace. Did you upload a Playwright HTML report instead? Make sure to extract the archive first and then double-click the index.html file or put it on a web server.');
else if (traceFileName)

View File

@ -153,4 +153,5 @@ body.dark-mode .window-header {
.snapshot-tab .cm-wrapper {
line-height: 23px;
margin-right: 4px;
}

View File

@ -137,7 +137,6 @@ export const SnapshotTab: React.FunctionComponent<{
setHighlightedLocator('');
setIsInspecting(!pickerVisible);
}}>Pick locator</ToolbarButton>
<div style={{ width: 5 }}></div>
{['action', 'before', 'after'].map(tab => {
return <TabbedPaneTab
id={tab}

View File

@ -21,3 +21,13 @@
display: flex;
flex-direction: row;
}
.source-tab-file-name {
height: 24px;
margin-left: 8px;
display: flex;
align-items: center;
background-color: var(--vscode-breadcrumb-background);
box-shadow: var(--vscode-scrollbar-shadow) 0 6px 6px -6px;
z-index: 200;
}

View File

@ -29,8 +29,9 @@ export const SourceTab: React.FunctionComponent<{
action: ActionTraceEvent | undefined,
sources: Map<string, SourceModel>,
hideStackFrames?: boolean,
rootDir?: string,
fallbackLocation?: StackFrame,
}> = ({ action, sources, hideStackFrames, fallbackLocation }) => {
}> = ({ action, sources, hideStackFrames, rootDir, fallbackLocation }) => {
const [lastAction, setLastAction] = React.useState<ActionTraceEvent | undefined>();
const [selectedFrame, setSelectedFrame] = React.useState<number>(0);
@ -41,7 +42,7 @@ export const SourceTab: React.FunctionComponent<{
}
}, [action, lastAction, setLastAction, setSelectedFrame]);
const { source, targetLine, highlight } = useAsyncMemo<{ source: SourceModel, targetLine: number, highlight: SourceHighlight[] }>(async () => {
const { source, highlight, targetLine, fileName } = useAsyncMemo<{ source: SourceModel, targetLine?: number, fileName?: string, highlight: SourceHighlight[] }>(async () => {
const location = action?.stack?.[selectedFrame] || fallbackLocation;
if (!location?.file)
return { source: { errors: [], content: undefined }, targetLine: 0, highlight: [] };
@ -54,6 +55,7 @@ export const SourceTab: React.FunctionComponent<{
}
const targetLine = location.line || 0;
const fileName = rootDir && location.file.startsWith(rootDir) ? location.file.substring(rootDir.length + 1) : location.file;
const highlight: SourceHighlight[] = source.errors.map(e => ({ type: 'error', line: e.location.line, message: e.error!.message }));
highlight.push({ line: targetLine, type: 'running' });
@ -68,11 +70,14 @@ export const SourceTab: React.FunctionComponent<{
source.content = `<Unable to read "${location.file}">`;
}
}
return { source, targetLine, highlight };
}, [action, selectedFrame, fallbackLocation], { source: { errors: [], content: 'Loading\u2026' }, targetLine: 0, highlight: [] });
return { source, highlight, targetLine, fileName };
}, [action, selectedFrame, rootDir, fallbackLocation], { source: { errors: [], content: 'Loading\u2026' }, highlight: [] });
return <SplitView sidebarSize={200} orientation='horizontal' sidebarHidden={hideStackFrames}>
<CodeMirrorWrapper text={source.content || ''} language='javascript' highlight={highlight} revealLine={targetLine} readOnly={true} lineNumbers={true} />
<div className='vbox' data-testid='source-code'>
{fileName && <div className='source-tab-file-name'>{fileName}</div>}
<CodeMirrorWrapper text={source.content || ''} language='javascript' highlight={highlight} revealLine={targetLine} readOnly={true} lineNumbers={true} />
</div>
<StackTraceView action={action} selectedFrame={selectedFrame} setSelectedFrame={setSelectedFrame} />
</SplitView>;
};

View File

@ -40,16 +40,9 @@
overflow: hidden
}
.watch-mode-sidebar .toolbar {
min-height: 24px;
}
.watch-mode-sidebar .toolbar-button {
margin: 0;
}
.watch-mode .section-title {
display: flex;
flex: auto;
flex-direction: row;
align-items: center;
font-size: 11px;
@ -58,6 +51,7 @@
text-overflow: ellipsis;
overflow: hidden;
padding: 8px;
height: 30px;
}
.watch-mode-sidebar img {
@ -68,20 +62,19 @@
}
.status-line {
flex: none;
flex: auto;
white-space: nowrap;
line-height: 22px;
padding: 0 10px;
color: var(--vscode-statusBar-foreground);
background-color: var(--vscode-statusBar-background);
padding-left: 10px;
display: flex;
flex-direction: row;
align-items: center;
height: 30px;
}
.status-line > div {
display: flex;
align-items: center;
margin: 0 5px;
overflow: hidden;
text-overflow: ellipsis;
}
.list-view-entry:not(.highlighted):not(.selected) .toolbar-button:not(.toggled) {
@ -103,7 +96,6 @@
flex: none;
display: flex;
flex-direction: column;
margin-top: 8px;
}
.filter-title,
@ -120,7 +112,7 @@
}
.filter-summary {
line-height: 24px;
line-height: 21px;
margin-top: 2px;
margin-left: 20px;
}

View File

@ -22,7 +22,7 @@ import { TreeView } from '@web/components/treeView';
import type { TreeState } from '@web/components/treeView';
import { baseFullConfig, TeleReporterReceiver, TeleSuite } from '@testIsomorphic/teleReceiver';
import type { TeleTestCase } from '@testIsomorphic/teleReceiver';
import type { FullConfig, Suite, TestCase, TestResult, Location } from '../../../playwright-test/types/testReporter';
import type { FullConfig, Suite, TestCase, Location } from '../../../playwright-test/types/testReporter';
import { SplitView } from '@web/components/splitView';
import { MultiTraceModel } from './modelUtil';
import './watchMode.css';
@ -36,7 +36,7 @@ import { toggleTheme } from '@web/theme';
import { artifactsFolderName } from '@testIsomorphic/folders';
import { settings } from '@web/uiUtils';
let updateRootSuite: (config: FullConfig, rootSuite: Suite, progress: Progress) => void = () => {};
let updateRootSuite: (config: FullConfig, rootSuite: Suite, progress: Progress | undefined) => void = () => {};
let runWatchedTests = (fileName: string) => {};
let xtermSize = { cols: 80, rows: 24 };
@ -67,17 +67,17 @@ export const WatchModeView: React.FC<{}> = ({
]));
const [projectFilters, setProjectFilters] = React.useState<Map<string, boolean>>(new Map());
const [testModel, setTestModel] = React.useState<TestModel>({ config: undefined, rootSuite: undefined });
const [progress, setProgress] = React.useState<Progress>({ total: 0, passed: 0, failed: 0, skipped: 0 });
const [selectedTest, setSelectedTest] = React.useState<TestCase | undefined>(undefined);
const [progress, setProgress] = React.useState<Progress & { total: number } | undefined>();
const [selectedItem, setSelectedItem] = React.useState<{ location?: Location, testCase?: TestCase }>({});
const [visibleTestIds, setVisibleTestIds] = React.useState<string[]>([]);
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const [runningState, setRunningState] = React.useState<{ testIds: Set<string>, itemSelectedByUser?: boolean }>();
const [runningState, setRunningState] = React.useState<{ testIds: Set<string>, itemSelectedByUser?: boolean } | undefined>();
const inputRef = React.useRef<HTMLInputElement>(null);
const reloadTests = () => {
setIsLoading(true);
updateRootSuite(baseFullConfig, new TeleSuite('', 'root'), { total: 0, passed: 0, failed: 0, skipped: 0 });
updateRootSuite(baseFullConfig, new TeleSuite('', 'root'), undefined);
refreshRootSuite(true).then(() => {
setIsLoading(false);
});
@ -88,7 +88,7 @@ export const WatchModeView: React.FC<{}> = ({
reloadTests();
}, []);
updateRootSuite = (config: FullConfig, rootSuite: Suite, newProgress: Progress) => {
updateRootSuite = (config: FullConfig, rootSuite: Suite, newProgress: Progress | undefined) => {
const selectedProjects = config.configFile ? settings.getObject<string[] | undefined>(config.configFile + ':projects', undefined) : undefined;
for (const projectName of projectFilters.keys()) {
if (!rootSuite.suites.find(s => s.title === projectName))
@ -103,7 +103,10 @@ export const WatchModeView: React.FC<{}> = ({
setTestModel({ config, rootSuite });
setProjectFilters(new Map(projectFilters));
setProgress(newProgress);
if (runningState && newProgress)
setProgress({ ...newProgress, total: runningState.testIds.size });
else if (!newProgress)
setProgress(undefined);
};
const runTests = (testIds: string[]) => {
@ -133,29 +136,27 @@ export const WatchModeView: React.FC<{}> = ({
};
const isRunningTest = !!runningState;
const result = selectedTest?.results[0];
const outputDir = selectedTest ? outputDirForTestCase(selectedTest) : undefined;
return <div className='vbox watch-mode'>
<SplitView sidebarSize={250} orientation='horizontal' sidebarIsFirst={true}>
<div className='vbox'>
<div className={'vbox' + (isShowingOutput ? '' : ' hidden')}>
<Toolbar>
<div className='section-title' style={{ flex: 'none' }}>Output</div>
<ToolbarButton icon='circle-slash' title='Clear output' onClick={() => xtermDataSource.clear()}></ToolbarButton>
<div className='spacer'></div>
<ToolbarButton icon='close' title='Close' onClick={() => setIsShowingOutput(false)}></ToolbarButton>
</Toolbar>
<XtermWrapper source={xtermDataSource}></XtermWrapper>;
<XtermWrapper source={xtermDataSource}></XtermWrapper>
</div>
<div className={'vbox' + (isShowingOutput ? ' hidden' : '')}>
<TraceView outputDir={outputDir} testCase={selectedTest} result={result} />
<TraceView item={selectedItem} rootDir={testModel.config?.rootDir} />
</div>
</div>
<div className='vbox watch-mode-sidebar'>
<Toolbar>
<Toolbar noShadow={true}>
<img src='icon-32x32.png' />
<div className='section-title'>Playwright</div>
<div className='spacer'></div>
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()} />
<ToolbarButton icon='refresh' title='Reload' onClick={() => reloadTests()} disabled={isRunningTest || isLoading}></ToolbarButton>
<ToolbarButton icon='terminal' title='Toggle output' toggled={isShowingOutput} onClick={() => { setIsShowingOutput(!isShowingOutput); }} />
@ -169,9 +170,14 @@ export const WatchModeView: React.FC<{}> = ({
setProjectFilters={setProjectFilters}
testModel={testModel}
runTests={() => runTests(visibleTestIds)} />
<Toolbar>
<div className='section-title'>Tests</div>
<div className='spacer'></div>
<Toolbar noMinHeight={true}>
{!isRunningTest && !progress && <div className='section-title'>Tests</div>}
{!isRunningTest && progress && <div data-testid='status-line' className='status-line'>
<div>{progress.passed}/{progress.total} passed ({(progress.passed / progress.total) * 100 | 0}%)</div>
</div>}
{isRunningTest && progress && <div data-testid='status-line' className='status-line'>
<div>Running {progress.passed}/{runningState.testIds.size} passed ({(progress.passed / runningState.testIds.size) * 100 | 0}%)</div>
</div>}
<ToolbarButton icon='play' title='Run all' onClick={() => runTests(visibleTestIds)} disabled={isRunningTest || isLoading}></ToolbarButton>
<ToolbarButton icon='debug-stop' title='Stop' onClick={() => sendMessageNoReply('stop')} disabled={!isRunningTest || isLoading}></ToolbarButton>
</Toolbar>
@ -182,19 +188,10 @@ export const WatchModeView: React.FC<{}> = ({
testModel={testModel}
runningState={runningState}
runTests={runTests}
onTestSelected={setSelectedTest}
onItemSelected={setSelectedItem}
setVisibleTestIds={setVisibleTestIds} />
</div>
</SplitView>
<div className='status-line'>
<div>Total: {progress.total}</div>
{isRunningTest && <div><span className='codicon codicon-loading'></span>{`Running ${visibleTestIds.length}\u2026`}</div>}
{isLoading && <div><span className='codicon codicon-loading'></span> {'Loading\u2026'}</div>}
{!isRunningTest && <div>Showing: {visibleTestIds.length}</div>}
<div>{progress.passed} passed</div>
<div>{progress.failed} failed</div>
<div>{progress.skipped} skipped</div>
</div>
</div>;
};
@ -276,11 +273,11 @@ const TestList: React.FC<{
runTests: (testIds: string[]) => void,
runningState?: { testIds: Set<string>, itemSelectedByUser?: boolean },
setVisibleTestIds: (testIds: string[]) => void,
onTestSelected: (test: TestCase | undefined) => void,
}> = ({ statusFilters, projectFilters, filterText, testModel, runTests, runningState, onTestSelected, setVisibleTestIds }) => {
onItemSelected: (item: { testCase?: TestCase, location?: Location }) => void,
}> = ({ statusFilters, projectFilters, filterText, testModel, runTests, runningState, onItemSelected, setVisibleTestIds }) => {
const [treeState, setTreeState] = React.useState<TreeState>({ expandedItems: new Map() });
const [selectedTreeItemId, setSelectedTreeItemId] = React.useState<string | undefined>();
const [watchedTreeIds] = React.useState<Set<string>>(new Set());
const [watchedTreeIds, innerSetWatchedTreeIds] = React.useState<{ value: Set<string> }>({ value: new Set() });
const { rootItem, treeItemMap } = React.useMemo(() => {
const rootItem = createTree(testModel.rootSuite, projectFilters);
@ -323,14 +320,15 @@ const TestList: React.FC<{
const { selectedTreeItem } = React.useMemo(() => {
const selectedTreeItem = selectedTreeItemId ? treeItemMap.get(selectedTreeItemId) : undefined;
const location = selectedTreeItem?.location;
let selectedTest: TestCase | undefined;
if (selectedTreeItem?.kind === 'test')
selectedTest = selectedTreeItem.test;
else if (selectedTreeItem?.kind === 'case' && selectedTreeItem.tests.length === 1)
selectedTest = selectedTreeItem.tests[0];
onTestSelected(selectedTest);
onItemSelected({ testCase: selectedTest, location });
return { selectedTreeItem };
}, [onTestSelected, selectedTreeItemId, treeItemMap]);
}, [onItemSelected, selectedTreeItemId, treeItemMap]);
const setWatchedTreeIds = (watchedTreeIds: Set<string>) => {
const fileNames = new Set<string>();
@ -339,6 +337,7 @@ const TestList: React.FC<{
fileNames.add(fileNameForTreeItem(treeItem)!);
}
sendMessageNoReply('watch', { fileNames: [...fileNames] });
innerSetWatchedTreeIds({ value: watchedTreeIds });
};
const runTreeItem = (treeItem: TreeItem) => {
@ -348,7 +347,7 @@ const TestList: React.FC<{
runWatchedTests = (fileName: string) => {
const testIds: string[] = [];
for (const treeId of watchedTreeIds) {
for (const treeId of watchedTreeIds.value) {
const treeItem = treeItemMap.get(treeId)!;
if (fileNameForTreeItem(treeItem) === fileName)
testIds.push(...collectTestIds(treeItem));
@ -367,12 +366,12 @@ const TestList: React.FC<{
<ToolbarButton icon='play' title='Run' onClick={() => runTreeItem(treeItem)} disabled={!!runningState}></ToolbarButton>
<ToolbarButton icon='go-to-file' title='Open in VS Code' onClick={() => sendMessageNoReply('open', { location: locationToOpen(treeItem) })}></ToolbarButton>
<ToolbarButton icon='eye' title='Watch' onClick={() => {
if (watchedTreeIds.has(treeItem.id))
watchedTreeIds.delete(treeItem.id);
if (watchedTreeIds.value.has(treeItem.id))
watchedTreeIds.value.delete(treeItem.id);
else
watchedTreeIds.add(treeItem.id);
setWatchedTreeIds(watchedTreeIds);
}} toggled={watchedTreeIds.has(treeItem.id)}></ToolbarButton>
watchedTreeIds.value.add(treeItem.id);
setWatchedTreeIds(watchedTreeIds.value);
}} toggled={watchedTreeIds.value.has(treeItem.id)}></ToolbarButton>
</div>;
}}
icon={treeItem => {
@ -398,14 +397,19 @@ const TestList: React.FC<{
};
const TraceView: React.FC<{
outputDir: string | undefined,
testCase: TestCase | undefined,
result: TestResult | undefined,
}> = ({ outputDir, testCase, result }) => {
item: { location?: Location, testCase?: TestCase },
rootDir?: string,
}> = ({ item, rootDir }) => {
const [model, setModel] = React.useState<MultiTraceModel | undefined>();
const [counter, setCounter] = React.useState(0);
const pollTimer = React.useRef<NodeJS.Timeout | null>(null);
const { outputDir, result } = React.useMemo(() => {
const outputDir = item.testCase ? outputDirForTestCase(item.testCase) : undefined;
const result = item.testCase?.results[0];
return { outputDir, result };
}, [item]);
React.useEffect(() => {
if (pollTimer.current)
clearTimeout(pollTimer.current);
@ -427,7 +431,7 @@ const TraceView: React.FC<{
return;
}
const traceLocation = `${outputDir}/${artifactsFolderName(result!.workerIndex)}/traces/${testCase?.id}.json`;
const traceLocation = `${outputDir}/${artifactsFolderName(result!.workerIndex)}/traces/${item.testCase?.id}.json`;
// Start polling running test.
pollTimer.current = setTimeout(async () => {
try {
@ -443,9 +447,16 @@ const TraceView: React.FC<{
if (pollTimer.current)
clearTimeout(pollTimer.current);
};
}, [result, outputDir, testCase, setModel, counter, setCounter]);
}, [result, outputDir, item, setModel, counter, setCounter]);
return <Workbench key='workbench' model={model} hideTimelineBars={true} hideStackFrames={true} showSourcesFirst={true} defaultSourceLocation={testCase?.location} />;
return <Workbench
key='workbench'
model={model}
hideTimelineBars={true}
hideStackFrames={true}
showSourcesFirst={true}
rootDir={rootDir}
defaultSourceLocation={item.location} />;
};
declare global {
@ -478,7 +489,6 @@ const refreshRootSuite = (eraseResults: boolean): Promise<void> => {
let rootSuite: Suite;
const progress: Progress = {
total: 0,
passed: 0,
failed: 0,
skipped: 0,
@ -489,7 +499,6 @@ const refreshRootSuite = (eraseResults: boolean): Promise<void> => {
if (!rootSuite)
rootSuite = suite;
config = c;
progress.total = suite.allTests().length;
progress.passed = 0;
progress.failed = 0;
progress.skipped = 0;
@ -587,7 +596,6 @@ const collectTestIds = (treeItem?: TreeItem): string[] => {
};
type Progress = {
total: number;
passed: number;
failed: number;
skipped: number;

View File

@ -37,14 +37,17 @@ export const Workbench: React.FunctionComponent<{
hideTimelineBars?: boolean,
hideStackFrames?: boolean,
showSourcesFirst?: boolean,
rootDir?: string,
defaultSourceLocation?: Location,
}> = ({ model, hideTimelineBars, hideStackFrames, showSourcesFirst, defaultSourceLocation }) => {
}> = ({ model, hideTimelineBars, hideStackFrames, showSourcesFirst, rootDir, defaultSourceLocation }) => {
const [selectedAction, setSelectedAction] = React.useState<ActionTraceEvent | undefined>(undefined);
const [highlightedAction, setHighlightedAction] = React.useState<ActionTraceEvent | undefined>();
const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions');
const [selectedPropertiesTab, setSelectedPropertiesTab] = React.useState<string>(showSourcesFirst ? 'source' : 'call');
const activeAction = model ? highlightedAction || selectedAction : undefined;
const sources = React.useMemo(() => model?.sources || new Map(), [model]);
React.useEffect(() => {
if (selectedAction && model?.actions.includes(selectedAction))
return;
@ -68,7 +71,12 @@ export const Workbench: React.FunctionComponent<{
const sourceTab: TabbedPaneTabModel = {
id: 'source',
title: 'Source',
render: () => <SourceTab action={activeAction} sources={model?.sources || new Map()} hideStackFrames={hideStackFrames} fallbackLocation={defaultSourceLocation}/>
render: () => <SourceTab
action={activeAction}
sources={sources}
hideStackFrames={hideStackFrames}
rootDir={rootDir}
fallbackLocation={defaultSourceLocation} />
};
const consoleTab: TabbedPaneTabModel = {
id: 'console',

View File

@ -109,7 +109,7 @@ body.dark-mode .CodeMirror span.cm-type {
}
.CodeMirror-cursor {
border-left: 1px solid #bebebe;
border-left: 1px solid var(--vscode-editor-foreground) !important;
}
.CodeMirror div.CodeMirror-selected {

View File

@ -149,9 +149,9 @@ export const CodeMirrorWrapper: React.FC<SourceProps> = ({
codemirrorRef.current!.highlight = highlight;
codemirrorRef.current!.widgets = widgets;
}
if (revealLine && codemirrorRef.current!.cm.lineCount() >= revealLine)
codemirror.scrollIntoView({ line: revealLine - 1, ch: 0 }, 50);
// Line-less locations have line = 0, but they mean to reveal the file.
if (typeof revealLine === 'number' && codemirrorRef.current!.cm.lineCount() >= revealLine)
codemirror.scrollIntoView({ line: Math.max(0, revealLine - 1), ch: 0 }, 50);
}, [codemirror, text, highlight, revealLine, focusOnChange, onChange]);
return <div className='cm-wrapper' ref={codemirrorElement}></div>;

View File

@ -53,7 +53,7 @@ export const TabbedPane: React.FunctionComponent<{
if (tab.component)
return <div key={tab.id} className='tab-content' style={{ display: selectedTab === tab.id ? 'inherit' : 'none' }}>{tab.component}</div>;
if (selectedTab === tab.id)
return <div key={tab.id} className='tab-content'>{tab.component || tab.render!()}</div>;
return <div key={tab.id} className='tab-content'>{tab.render!()}</div>;
})
}
</div>

View File

@ -15,19 +15,35 @@
*/
.toolbar {
position: relative;
display: flex;
box-shadow: var(--box-shadow);
background-color: var(--vscode-sideBar-background);
color: var(--vscode-sideBarTitle-foreground);
min-height: 35px;
align-items: center;
flex: none;
z-index: 2;
padding-right: 2px;
}
.toolbar-linewrap {
.toolbar:after {
content: '';
display: block;
flex: auto;
position: absolute;
pointer-events: none;
top: 0;
bottom: 0;
left: -2px;
right: -2px;
box-shadow: var(--vscode-scrollbar-shadow) 0 6px 6px -6px;
z-index: 100;
}
.toolbar.no-shadow:after {
box-shadow: none;
}
.toolbar.no-min-height {
min-height: 0;
}
.toolbar input {

View File

@ -17,11 +17,15 @@
import './toolbar.css';
import * as React from 'react';
export interface ToolbarProps {
}
type ToolbarProps = {
noShadow?: boolean;
noMinHeight?: boolean;
};
export const Toolbar: React.FC<React.PropsWithChildren<ToolbarProps>> = ({
children
noShadow,
children,
noMinHeight
}) => {
return <div className='toolbar'>{children}</div>;
return <div className={'toolbar' + (noShadow ? ' no-shadow' : '') + (noMinHeight ? ' no-min-height' : '')}>{children}</div>;
};

View File

@ -21,7 +21,7 @@
color: var(--vscode-sideBarTitle-foreground);
background: transparent;
padding: 4px;
margin: 0 4px;
margin: 0 2px;
cursor: pointer;
display: inline-flex;
align-items: center;
@ -43,3 +43,7 @@
.toolbar-button.toggled {
color: var(--vscode-notificationLink-foreground);
}
.toolbar-button.toggled .codicon {
font-weight: bold;
}

View File

@ -41,6 +41,7 @@ export const ToolbarButton: React.FC<React.PropsWithChildren<ToolbarButtonProps>
className={className}
onMouseDown={preventDefault}
onClick={onClick}
onDoubleClick={preventDefault}
title={title}
disabled={!!disabled}>
{icon && <span className={`codicon codicon-${icon}`} style={children ? { marginRight: 5 } : {}}></span>}
@ -48,4 +49,7 @@ export const ToolbarButton: React.FC<React.PropsWithChildren<ToolbarButtonProps>
</button>;
};
const preventDefault = (e: any) => e.preventDefault();
const preventDefault = (e: any) => {
e.stopPropagation();
e.preventDefault();
};

View File

@ -17,8 +17,9 @@
import * as React from 'react';
import './xtermWrapper.css';
import type { ITheme, Terminal } from 'xterm';
import type { FitAddon } from 'xterm-addon-fit';
import type { XtermModule } from './xtermModule';
import { isDarkTheme } from '@web/theme';
import { currentTheme, addThemeListener, removeThemeListener } from '@web/theme';
export type XtermDataSource = {
pending: (string | Uint8Array)[];
@ -28,12 +29,23 @@ export type XtermDataSource = {
};
export const XtermWrapper: React.FC<{ source: XtermDataSource }> = ({
source
source,
}) => {
const xtermElement = React.useRef<HTMLDivElement>(null);
const [theme, setTheme] = React.useState(currentTheme());
const [modulePromise] = React.useState<Promise<XtermModule>>(import('./xtermModule').then(m => m.default));
const terminal = React.useRef<Terminal | null>(null);
const terminal = React.useRef<{ terminal: Terminal, fitAddon: FitAddon }| null>(null);
React.useEffect(() => {
addThemeListener(setTheme);
return () => removeThemeListener(setTheme);
}, []);
React.useEffect(() => {
const oldSourceWrite = source.write;
const oldSourceClear = source.clear;
let resizeObserver: ResizeObserver | undefined;
(async () => {
// Always load the module first.
const { Terminal, FitAddon } = await modulePromise;
@ -41,7 +53,7 @@ export const XtermWrapper: React.FC<{ source: XtermDataSource }> = ({
if (!element)
return;
if (terminal.current)
if (terminal.current && terminal)
return;
const newTerminal = new Terminal({
@ -49,7 +61,7 @@ export const XtermWrapper: React.FC<{ source: XtermDataSource }> = ({
fontSize: 13,
scrollback: 10000,
fontFamily: 'var(--vscode-editor-font-family)',
theme: isDarkTheme() ? darkTheme : lightTheme
theme: theme === 'dark-mode' ? darkTheme : lightTheme
});
const fitAddon = new FitAddon();
@ -66,16 +78,30 @@ export const XtermWrapper: React.FC<{ source: XtermDataSource }> = ({
};
newTerminal.open(element);
fitAddon.fit();
terminal.current = newTerminal;
const resizeObserver = new ResizeObserver(() => {
source.resize(newTerminal.cols, newTerminal.rows);
fitAddon.fit();
terminal.current = { terminal: newTerminal, fitAddon };
resizeObserver = new ResizeObserver(() => {
// Fit reads data from the terminal itself, which updates lazily, probably on some timer
// or mutation observer. Work around it.
setTimeout(() => {
fitAddon.fit();
source.resize(newTerminal.cols, newTerminal.rows);
}, 100);
});
resizeObserver.observe(element);
})();
}, [modulePromise, terminal, xtermElement, source]);
return <div className='xterm-wrapper' style={{ flex: 'auto' }} ref={xtermElement}>
</div>;
return () => {
source.clear = oldSourceClear;
source.write = oldSourceWrite;
resizeObserver?.disconnect();
};
}, [modulePromise, terminal, xtermElement, source, theme]);
React.useEffect(() => {
if (terminal.current)
terminal.current.terminal.options.theme = theme === 'dark-mode' ? darkTheme : lightTheme;
}, [theme]);
return <div className='xterm-wrapper' style={{ flex: 'auto' }} ref={xtermElement}></div>;
};
const lightTheme: ITheme = {

View File

@ -34,9 +34,12 @@ export function applyTheme() {
document.body.classList.add('dark-mode');
}
type Theme = 'dark-mode' | 'light-mode';
const listeners = new Set<(theme: Theme) => void>();
export function toggleTheme() {
const oldTheme = settings.getString('theme', 'light-mode');
let newTheme: string;
let newTheme: Theme;
if (oldTheme === 'dark-mode')
newTheme = 'light-mode';
else
@ -46,8 +49,18 @@ export function toggleTheme() {
document.body.classList.remove(oldTheme);
document.body.classList.add(newTheme);
settings.setString('theme', newTheme);
for (const listener of listeners)
listener(newTheme);
}
export function isDarkTheme() {
return document.body.classList.contains('dark-mode');
export function addThemeListener(listener: (theme: 'light-mode' | 'dark-mode') => void) {
listeners.add(listener);
}
export function removeThemeListener(listener: (theme: Theme) => void) {
listeners.delete(listener);
}
export function currentTheme(): Theme {
return document.body.classList.contains('dark-mode') ? 'dark-mode' : 'light-mode';
}

View File

@ -59,6 +59,27 @@ test('should run visible', async ({ runUITest }) => {
passes
skipped
`);
await expect(page.getByTestId('status-line')).toHaveText('4/8 passed (50%)');
});
test('should show running progress', async ({ runUITest }) => {
const page = await runUITest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('test 1', async () => {});
test('test 2', async () => new Promise(() => {}));
test('test 3', async () => {});
test('test 4', async () => {});
`,
});
await page.getByTitle('Run all').click();
await expect(page.getByTestId('status-line')).toHaveText('Running 1/4 passed (25%)');
await page.getByTitle('Stop').click();
await expect(page.getByTestId('status-line')).toHaveText('1/4 passed (25%)');
await page.getByTitle('Reload').click();
await expect(page.getByTestId('status-line')).toBeHidden();
});
test('should run on hover', async ({ runUITest }) => {

View File

@ -41,16 +41,25 @@ test('should show selected test in sources', async ({ runUITest }) => {
`);
await page.getByTestId('test-tree').getByText('first').click();
await expect(
page.getByTestId('source-code').locator('.source-tab-file-name')
).toHaveText('a.test.ts');
await expect(
page.locator('.CodeMirror .source-line-running'),
).toHaveText(`3 test('first', () => {});`);
await page.getByTestId('test-tree').getByText('second').click();
await expect(
page.getByTestId('source-code').locator('.source-tab-file-name')
).toHaveText('a.test.ts');
await expect(
page.locator('.CodeMirror .source-line-running'),
).toHaveText(`4 test('second', () => {});`);
await page.getByTestId('test-tree').getByText('third').click();
await expect(
page.getByTestId('source-code').locator('.source-tab-file-name')
).toHaveText('b.test.ts');
await expect(
page.locator('.CodeMirror .source-line-running'),
).toHaveText(`3 test('third', () => {});`);

View File

@ -85,7 +85,7 @@ test('should traverse up/down', async ({ runUITest }) => {
test('should expand / collapse groups', async ({ runUITest }) => {
const page = await runUITest(basicTestTree);
await page.getByText('suite').click();
await page.getByTestId('test-tree').getByText('suite').click();
await page.keyboard.press('ArrowRight');
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
a.test.ts
@ -104,7 +104,7 @@ test('should expand / collapse groups', async ({ runUITest }) => {
suite <=
`);
await page.getByText('passes').first().click();
await page.getByTestId('test-tree').getByText('passes').first().click();
await page.keyboard.press('ArrowLeft');
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
a.test.ts <=

View File

@ -29,6 +29,12 @@ test('should watch files', async ({ runUITest, writeFiles }) => {
await page.getByText('fails').click();
await page.getByRole('listitem').filter({ hasText: 'fails' }).getByTitle('Watch').click();
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
passes
fails 👁 <=
`);
await page.getByRole('listitem').filter({ hasText: 'fails' }).getByTitle('Run').click();
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`