2023-03-01 15:27:23 -08:00
|
|
|
|
/**
|
|
|
|
|
* 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 '@web/third_party/vscode/codicon.css';
|
|
|
|
|
import { loadSingleTraceFile, Workbench } from './workbench';
|
|
|
|
|
import '@web/common.css';
|
|
|
|
|
import React from 'react';
|
|
|
|
|
import { ListView } from '@web/components/listView';
|
|
|
|
|
import { TeleReporterReceiver } from '../../../playwright-test/src/isomorphic/teleReceiver';
|
|
|
|
|
import type { FullConfig, Suite, TestCase, TestStep } from '../../../playwright-test/types/testReporter';
|
|
|
|
|
import { SplitView } from '@web/components/splitView';
|
|
|
|
|
import type { MultiTraceModel } from './modelUtil';
|
|
|
|
|
import './watchMode.css';
|
2023-03-02 13:45:15 -08:00
|
|
|
|
import { ToolbarButton } from '@web/components/toolbarButton';
|
2023-03-01 15:27:23 -08:00
|
|
|
|
|
|
|
|
|
let rootSuite: Suite | undefined;
|
|
|
|
|
|
|
|
|
|
let updateList: () => void = () => {};
|
|
|
|
|
let updateProgress: () => void = () => {};
|
|
|
|
|
|
|
|
|
|
type Entry = { test?: TestCase, fileSuite: Suite };
|
|
|
|
|
|
|
|
|
|
export const WatchModeView: React.FC<{}> = ({
|
|
|
|
|
}) => {
|
|
|
|
|
const [updateCounter, setUpdateCounter] = React.useState(0);
|
|
|
|
|
updateList = () => setUpdateCounter(updateCounter + 1);
|
|
|
|
|
const [selectedFileSuite, setSelectedFileSuite] = React.useState<Suite | undefined>();
|
|
|
|
|
const [selectedTest, setSelectedTest] = React.useState<TestCase | undefined>();
|
|
|
|
|
const [isRunningTest, setIsRunningTest] = React.useState<boolean>(false);
|
|
|
|
|
const [expandedFiles] = React.useState(new Map<Suite, boolean | undefined>());
|
|
|
|
|
const [filterText, setFilterText] = React.useState<string>('');
|
|
|
|
|
|
|
|
|
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
inputRef.current?.focus();
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const selectedOrDefaultFileSuite = selectedFileSuite || rootSuite?.suites?.[0]?.suites?.[0];
|
|
|
|
|
const tests: TestCase[] = [];
|
|
|
|
|
const fileSuites: Suite[] = [];
|
|
|
|
|
|
|
|
|
|
for (const projectSuite of rootSuite?.suites || []) {
|
|
|
|
|
for (const fileSuite of projectSuite.suites) {
|
|
|
|
|
if (fileSuite === selectedOrDefaultFileSuite)
|
|
|
|
|
tests.push(...fileSuite.allTests());
|
|
|
|
|
fileSuites.push(fileSuite);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const explicitlyOrAutoExpandedFiles = new Set<Suite>();
|
|
|
|
|
const entries = new Map<TestCase | Suite, Entry>();
|
|
|
|
|
const trimmedFilterText = filterText.trim();
|
2023-03-02 13:45:15 -08:00
|
|
|
|
const filterTokens = trimmedFilterText.toLowerCase().split(' ');
|
2023-03-01 15:27:23 -08:00
|
|
|
|
for (const fileSuite of fileSuites) {
|
|
|
|
|
const hasMatch = !trimmedFilterText || fileSuite.allTests().some(test => {
|
2023-03-02 13:45:15 -08:00
|
|
|
|
const fullTitle = test.titlePath().join(' ').toLowerCase();
|
2023-03-01 15:27:23 -08:00
|
|
|
|
return !filterTokens.some(token => !fullTitle.includes(token));
|
|
|
|
|
});
|
|
|
|
|
if (hasMatch)
|
|
|
|
|
entries.set(fileSuite, { fileSuite });
|
|
|
|
|
const expandState = expandedFiles.get(fileSuite);
|
|
|
|
|
const autoExpandMatches = entries.size < 100 && (trimmedFilterText && hasMatch && expandState !== false);
|
|
|
|
|
if (expandState === true || autoExpandMatches) {
|
|
|
|
|
explicitlyOrAutoExpandedFiles.add(fileSuite);
|
|
|
|
|
for (const test of fileSuite.allTests()) {
|
2023-03-02 13:45:15 -08:00
|
|
|
|
const fullTitle = test.titlePath().join(' ').toLowerCase();
|
2023-03-01 15:27:23 -08:00
|
|
|
|
if (!filterTokens.some(token => !fullTitle.includes(token)))
|
|
|
|
|
entries.set(test, { test, fileSuite });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-02 13:45:15 -08:00
|
|
|
|
const visibleTestIds = new Set<string>();
|
|
|
|
|
for (const { test } of entries.values()) {
|
|
|
|
|
if (test)
|
|
|
|
|
visibleTestIds.add(test.id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const runEntry = (entry: Entry) => {
|
|
|
|
|
expandedFiles.set(entry.fileSuite, true);
|
|
|
|
|
setSelectedTest(entry.test);
|
|
|
|
|
setIsRunningTest(true);
|
|
|
|
|
runTests(entry.test ? entry.test.location.file + ':' + entry.test.location.line : entry.fileSuite.title, undefined).then(() => {
|
|
|
|
|
setIsRunningTest(false);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2023-03-01 15:27:23 -08:00
|
|
|
|
const selectedEntry = selectedTest ? entries.get(selectedTest) : selectedOrDefaultFileSuite ? entries.get(selectedOrDefaultFileSuite) : undefined;
|
|
|
|
|
return <SplitView sidebarSize={300} orientation='horizontal' sidebarIsFirst={true}>
|
|
|
|
|
<TraceView test={selectedTest} isRunningTest={isRunningTest}></TraceView>
|
|
|
|
|
<div className='vbox watch-mode-sidebar'>
|
|
|
|
|
<div style={{ flex: 'none', display: 'flex', padding: 4 }}>
|
|
|
|
|
<input ref={inputRef} type='search' placeholder='Filter tests' spellCheck={false} value={filterText}
|
|
|
|
|
onChange={e => {
|
|
|
|
|
setFilterText(e.target.value);
|
|
|
|
|
}}
|
|
|
|
|
onKeyDown={e => {
|
2023-03-02 13:45:15 -08:00
|
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
setIsRunningTest(true);
|
|
|
|
|
runTests(undefined, [...visibleTestIds]).then(() => {
|
|
|
|
|
setIsRunningTest(false);
|
|
|
|
|
});
|
|
|
|
|
}
|
2023-03-01 15:27:23 -08:00
|
|
|
|
}}></input>
|
|
|
|
|
</div>
|
|
|
|
|
<ListView
|
|
|
|
|
items={[...entries.values()]}
|
|
|
|
|
itemKey={(entry: Entry) => entry.test ? entry.test!.id : entry.fileSuite.title }
|
2023-03-02 13:45:15 -08:00
|
|
|
|
itemRender={(entry: Entry) => {
|
|
|
|
|
return <div className='hbox watch-mode-list-item'>
|
|
|
|
|
<div className='watch-mode-list-item-title'>{entry.test ? entry.test!.titlePath().slice(3).join(' › ') : entry.fileSuite.title}</div>
|
|
|
|
|
<ToolbarButton icon='play' title='Run' onClick={() => runEntry(entry)} disabled={isRunningTest}></ToolbarButton>
|
|
|
|
|
</div>;
|
|
|
|
|
}}
|
2023-03-01 15:27:23 -08:00
|
|
|
|
itemIcon={(entry: Entry) => {
|
|
|
|
|
if (entry.test) {
|
|
|
|
|
if (entry.test.results.length && entry.test.results[0].duration)
|
|
|
|
|
return entry.test.ok() ? 'codicon-check' : 'codicon-error';
|
|
|
|
|
if (entry.test.results.length)
|
|
|
|
|
return 'codicon-loading';
|
|
|
|
|
} else {
|
|
|
|
|
if (explicitlyOrAutoExpandedFiles.has(entry.fileSuite))
|
|
|
|
|
return 'codicon-chevron-down';
|
|
|
|
|
return 'codicon-chevron-right';
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
itemIndent={(entry: Entry) => entry.test ? 1 : 0}
|
|
|
|
|
selectedItem={selectedEntry}
|
2023-03-02 13:45:15 -08:00
|
|
|
|
onAccepted={runEntry}
|
2023-03-01 15:27:23 -08:00
|
|
|
|
onLeftArrow={(entry: Entry) => {
|
|
|
|
|
expandedFiles.set(entry.fileSuite, false);
|
|
|
|
|
setSelectedTest(undefined);
|
|
|
|
|
setSelectedFileSuite(entry.fileSuite);
|
|
|
|
|
updateList();
|
|
|
|
|
}}
|
|
|
|
|
onRightArrow={(entry: Entry) => {
|
|
|
|
|
expandedFiles.set(entry.fileSuite, true);
|
|
|
|
|
updateList();
|
|
|
|
|
}}
|
|
|
|
|
onSelected={(entry: Entry) => {
|
|
|
|
|
if (entry.test) {
|
|
|
|
|
setSelectedFileSuite(undefined);
|
|
|
|
|
setSelectedTest(entry.test!);
|
|
|
|
|
} else {
|
|
|
|
|
setSelectedTest(undefined);
|
|
|
|
|
setSelectedFileSuite(entry.fileSuite);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
onIconClicked={(entry: Entry) => {
|
|
|
|
|
if (explicitlyOrAutoExpandedFiles.has(entry.fileSuite))
|
|
|
|
|
expandedFiles.set(entry.fileSuite, false);
|
|
|
|
|
else
|
|
|
|
|
expandedFiles.set(entry.fileSuite, true);
|
|
|
|
|
updateList();
|
|
|
|
|
}}
|
|
|
|
|
showNoItemsMessage={true}></ListView>
|
|
|
|
|
</div>
|
|
|
|
|
</SplitView>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const ProgressView: React.FC<{
|
|
|
|
|
test: TestCase | undefined,
|
|
|
|
|
}> = ({
|
|
|
|
|
test,
|
|
|
|
|
}) => {
|
|
|
|
|
const [updateCounter, setUpdateCounter] = React.useState(0);
|
|
|
|
|
updateProgress = () => setUpdateCounter(updateCounter + 1);
|
|
|
|
|
|
|
|
|
|
const steps: (TestCase | TestStep)[] = [];
|
|
|
|
|
for (const result of test?.results || [])
|
|
|
|
|
steps.push(...result.steps);
|
|
|
|
|
|
|
|
|
|
return <ListView
|
|
|
|
|
items={steps}
|
|
|
|
|
itemRender={(step: TestStep) => step.title}
|
|
|
|
|
itemIcon={(step: TestStep) => step.error ? 'codicon-error' : 'codicon-check'}
|
|
|
|
|
></ListView>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const TraceView: React.FC<{
|
|
|
|
|
test: TestCase | undefined,
|
|
|
|
|
isRunningTest: boolean,
|
|
|
|
|
}> = ({ test, isRunningTest }) => {
|
|
|
|
|
const [model, setModel] = React.useState<MultiTraceModel | undefined>();
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
(async () => {
|
|
|
|
|
if (!test) {
|
|
|
|
|
setModel(undefined);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
for (const result of test.results) {
|
|
|
|
|
const attachment = result.attachments.find(a => a.name === 'trace');
|
|
|
|
|
if (attachment && attachment.path) {
|
|
|
|
|
setModel(await loadSingleTraceFile(attachment.path));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
setModel(undefined);
|
|
|
|
|
})();
|
|
|
|
|
}, [test, isRunningTest]);
|
|
|
|
|
|
|
|
|
|
if (isRunningTest)
|
|
|
|
|
return <ProgressView test={test}></ProgressView>;
|
|
|
|
|
|
|
|
|
|
if (!model) {
|
|
|
|
|
return <div className='vbox'>
|
|
|
|
|
<div className='drop-target'>
|
|
|
|
|
<div>Run test to see the trace</div>
|
|
|
|
|
<div style={{ paddingTop: 20 }}>
|
|
|
|
|
<div>Double click a test or hit Enter</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return <Workbench model={model} view='embedded'></Workbench>;
|
|
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
declare global {
|
|
|
|
|
interface Window {
|
|
|
|
|
binding(data: any): Promise<void>;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const receiver = new TeleReporterReceiver({
|
|
|
|
|
onBegin: (config: FullConfig, suite: Suite) => {
|
|
|
|
|
if (!rootSuite)
|
|
|
|
|
rootSuite = suite;
|
|
|
|
|
updateList();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
onTestBegin: () => {
|
|
|
|
|
updateList();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
onTestEnd: () => {
|
|
|
|
|
updateList();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
onStepBegin: () => {
|
|
|
|
|
updateProgress();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
onStepEnd: () => {
|
|
|
|
|
updateProgress();
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
(window as any).dispatch = (message: any) => {
|
|
|
|
|
receiver.dispatch(message);
|
|
|
|
|
};
|
|
|
|
|
|
2023-03-02 13:45:15 -08:00
|
|
|
|
async function runTests(location: string | undefined, testIds: string[] | undefined): Promise<void> {
|
2023-03-01 15:27:23 -08:00
|
|
|
|
await (window as any).binding({
|
|
|
|
|
method: 'run',
|
2023-03-02 13:45:15 -08:00
|
|
|
|
params: { location, testIds }
|
2023-03-01 15:27:23 -08:00
|
|
|
|
});
|
|
|
|
|
}
|