chore: render parse errors in the UI mode (#22888)

Fixes: https://github.com/microsoft/playwright/issues/22863
This commit is contained in:
Pavel Feldman 2023-05-08 18:51:27 -07:00 committed by GitHub
parent 5fb426e7db
commit b10cc03314
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 134 additions and 68 deletions

View File

@ -22,7 +22,7 @@ import { serializeError } from '../util';
import type { InternalReporter } from '../reporters/internalReporter';
type TaskTeardown = () => Promise<any> | undefined;
export type Task<Context> = (context: Context, errors: TestError[]) => Promise<TaskTeardown | void> | undefined;
export type Task<Context> = (context: Context, errors: TestError[], softErrors: TestError[]) => Promise<TaskTeardown | void> | undefined;
export class TaskRunner<Context> {
private _tasks: { name: string, task: Task<Context> }[] = [];
@ -62,15 +62,16 @@ export class TaskRunner<Context> {
break;
debug('pw:test:task')(`"${name}" started`);
const errors: TestError[] = [];
const softErrors: TestError[] = [];
try {
const teardown = await task(context, errors);
const teardown = await task(context, errors, softErrors);
if (teardown)
teardownRunner._tasks.unshift({ name: `teardown for ${name}`, task: teardown });
} catch (e) {
debug('pw:test:task')(`error in "${name}": `, e);
errors.push(serializeError(e));
} finally {
for (const error of errors)
for (const error of [...softErrors, ...errors])
this._reporter.onError?.(error);
if (errors.length) {
if (!this._isTearDown)

View File

@ -63,7 +63,7 @@ export class TestRun {
export function createTaskRunner(config: FullConfigInternal, reporter: InternalReporter): TaskRunner<TestRun> {
const taskRunner = new TaskRunner<TestRun>(reporter, config.config.globalTimeout);
addGlobalSetupTasks(taskRunner, config);
taskRunner.addTask('load tests', createLoadTask('in-process', true));
taskRunner.addTask('load tests', createLoadTask('in-process', { filterOnly: true, failOnLoadErrors: true }));
addRunTasks(taskRunner, config);
return taskRunner;
}
@ -76,7 +76,7 @@ export function createTaskRunnerForWatchSetup(config: FullConfigInternal, report
export function createTaskRunnerForWatch(config: FullConfigInternal, reporter: InternalReporter, additionalFileMatcher?: Matcher): TaskRunner<TestRun> {
const taskRunner = new TaskRunner<TestRun>(reporter, 0);
taskRunner.addTask('load tests', createLoadTask('out-of-process', true, additionalFileMatcher));
taskRunner.addTask('load tests', createLoadTask('out-of-process', { filterOnly: true, failOnLoadErrors: false, additionalFileMatcher }));
addRunTasks(taskRunner, config);
return taskRunner;
}
@ -104,7 +104,7 @@ function addRunTasks(taskRunner: TaskRunner<TestRun>, config: FullConfigInternal
export function createTaskRunnerForList(config: FullConfigInternal, reporter: InternalReporter, mode: 'in-process' | 'out-of-process'): TaskRunner<TestRun> {
const taskRunner = new TaskRunner<TestRun>(reporter, config.config.globalTimeout);
taskRunner.addTask('load tests', createLoadTask(mode, false));
taskRunner.addTask('load tests', createLoadTask(mode, { filterOnly: false, failOnLoadErrors: false }));
taskRunner.addTask('report begin', async ({ reporter, rootSuite }) => {
reporter.onBegin(config.config, rootSuite!);
return () => reporter.onEnd();
@ -166,11 +166,11 @@ function createRemoveOutputDirsTask(): Task<TestRun> {
};
}
function createLoadTask(mode: 'out-of-process' | 'in-process', shouldFilterOnly: boolean, additionalFileMatcher?: Matcher): Task<TestRun> {
return async (testRun, errors) => {
await collectProjectsAndTestFiles(testRun, additionalFileMatcher);
await loadFileSuites(testRun, mode, errors);
testRun.rootSuite = await createRootSuite(testRun, errors, shouldFilterOnly);
function createLoadTask(mode: 'out-of-process' | 'in-process', options: { filterOnly: boolean, failOnLoadErrors: boolean, additionalFileMatcher?: Matcher }): Task<TestRun> {
return async (testRun, errors, softErrors) => {
await collectProjectsAndTestFiles(testRun, options.additionalFileMatcher);
await loadFileSuites(testRun, mode, options.failOnLoadErrors ? errors : softErrors);
testRun.rootSuite = await createRootSuite(testRun, options.failOnLoadErrors ? errors : softErrors, !!options.filterOnly);
// Fail when no tests.
if (!testRun.rootSuite.allTests().length && !testRun.config.cliPassWithNoTests && !testRun.config.config.shard)
throw new Error(`No tests found`);

View File

@ -19,7 +19,6 @@ import type { ResourceSnapshot } from '@trace/snapshot';
import type * as trace from '@trace/trace';
import type { ActionTraceEvent, EventTraceEvent } from '@trace/trace';
import type { ContextEntry, PageEntry } from '../entries';
import type { SerializedError, StackFrame } from '@protocol/channels';
const contextSymbol = Symbol('context');
const nextInContextSymbol = Symbol('next');
@ -27,8 +26,14 @@ const prevInListSymbol = Symbol('prev');
const eventsSymbol = Symbol('events');
const resourcesSymbol = Symbol('resources');
export type SourceLocation = {
file: string;
line: number;
source: SourceModel;
};
export type SourceModel = {
errors: { error: SerializedError['error'], location: StackFrame }[];
errors: { line: number, message: string }[];
content: string | undefined;
};
@ -203,7 +208,7 @@ function collectSources(actions: trace.ActionTraceEvent[]): Map<string, SourceMo
}
}
if (action.error && action.stack?.[0])
result.get(action.stack[0].file)!.errors.push({ error: action.error, location: action.stack?.[0] });
result.get(action.stack[0].file)!.errors.push({ line: action.stack?.[0].line || 0, message: action.error.message });
}
return result;
}

View File

@ -22,15 +22,14 @@ import './sourceTab.css';
import { StackTraceView } from './stackTrace';
import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
import type { SourceHighlight } from '@web/components/codeMirrorWrapper';
import type { SourceModel } from './modelUtil';
import type { StackFrame } from '@protocol/channels';
import type { SourceLocation, SourceModel } from './modelUtil';
export const SourceTab: React.FunctionComponent<{
action: ActionTraceEvent | undefined,
sources: Map<string, SourceModel>,
hideStackFrames?: boolean,
rootDir?: string,
fallbackLocation?: StackFrame,
fallbackLocation?: SourceLocation,
}> = ({ action, sources, hideStackFrames, rootDir, fallbackLocation }) => {
const [lastAction, setLastAction] = React.useState<ActionTraceEvent | undefined>();
const [selectedFrame, setSelectedFrame] = React.useState<number>(0);
@ -43,31 +42,34 @@ export const SourceTab: React.FunctionComponent<{
}, [action, lastAction, setLastAction, setSelectedFrame]);
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: [] };
const actionLocation = action?.stack?.[selectedFrame];
const shouldUseFallback = !actionLocation?.file;
if (shouldUseFallback && !shouldUseFallback)
return { source: { file: '', errors: [], content: undefined }, targetLine: 0, highlight: [] };
let source = sources.get(location.file);
const file = shouldUseFallback ? fallbackLocation!.file : actionLocation.file;
let source = sources.get(file);
// Fallback location can fall outside the sources model.
if (!source) {
source = { errors: [], content: undefined };
sources.set(location.file, source);
source = { errors: fallbackLocation?.source?.errors || [], content: undefined };
sources.set(file, source);
}
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 }));
const targetLine = shouldUseFallback ? fallbackLocation?.line || source.errors[0]?.line || 0 : actionLocation.line;
const fileName = rootDir && file.startsWith(rootDir) ? file.substring(rootDir.length + 1) : file;
const highlight: SourceHighlight[] = source.errors.map(e => ({ type: 'error', line: e.line, message: e.message }));
highlight.push({ line: targetLine, type: 'running' });
if (source.content === undefined || fallbackLocation) {
const sha1 = await calculateSha1(location.file);
// After the source update, but before the test run, don't trust the cache.
if (source.content === undefined || shouldUseFallback) {
const sha1 = await calculateSha1(file);
try {
let response = await fetch(`sha1/src@${sha1}.txt`);
if (response.status === 404)
response = await fetch(`file?path=${location.file}`);
response = await fetch(`file?path=${file}`);
source.content = await response.text();
} catch {
source.content = `<Unable to read "${location.file}">`;
source.content = `<Unable to read "${file}">`;
}
}
return { source, highlight, targetLine, fileName };

View File

@ -25,6 +25,7 @@ import type { TeleTestCase } from '@testIsomorphic/teleReceiver';
import type { FullConfig, Suite, TestCase, Location, TestError } from '@playwright/test/types/testReporter';
import { SplitView } from '@web/components/splitView';
import { idForAction, MultiTraceModel } from './modelUtil';
import type { SourceLocation } from './modelUtil';
import './uiModeView.css';
import { ToolbarButton } from '@web/components/toolbarButton';
import { Toolbar } from '@web/components/toolbar';
@ -37,7 +38,7 @@ import { artifactsFolderName } from '@testIsomorphic/folders';
import { msToString, settings, useSetting } from '@web/uiUtils';
import type { ActionTraceEvent } from '@trace/trace';
let updateRootSuite: (config: FullConfig, rootSuite: Suite, progress: Progress | undefined) => void = () => {};
let updateRootSuite: (config: FullConfig, rootSuite: Suite, loadErrors: TestError[], progress: Progress | undefined) => void = () => {};
let runWatchedTests = (fileNames: string[]) => {};
let xtermSize = { cols: 80, rows: 24 };
@ -54,6 +55,7 @@ const xtermDataSource: XtermDataSource = {
type TestModel = {
config: FullConfig | undefined;
rootSuite: Suite | undefined;
loadErrors: TestError[];
};
export const UIModeView: React.FC<{}> = ({
@ -67,9 +69,9 @@ export const UIModeView: React.FC<{}> = ({
['skipped', false],
]));
const [projectFilters, setProjectFilters] = React.useState<Map<string, boolean>>(new Map());
const [testModel, setTestModel] = React.useState<TestModel>({ config: undefined, rootSuite: undefined });
const [testModel, setTestModel] = React.useState<TestModel>({ config: undefined, rootSuite: undefined, loadErrors: [] });
const [progress, setProgress] = React.useState<Progress & { total: number } | undefined>();
const [selectedItem, setSelectedItem] = React.useState<{ location?: Location, testCase?: TestCase }>({});
const [selectedItem, setSelectedItem] = React.useState<{ testFile?: SourceLocation, testCase?: TestCase }>({});
const [visibleTestIds, setVisibleTestIds] = React.useState<Set<string>>(new Set());
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const [runningState, setRunningState] = React.useState<{ testIds: Set<string>, itemSelectedByUser?: boolean } | undefined>();
@ -81,21 +83,21 @@ export const UIModeView: React.FC<{}> = ({
const inputRef = React.useRef<HTMLInputElement>(null);
const reloadTests = () => {
const reloadTests = React.useCallback(() => {
setIsLoading(true);
setWatchedTreeIds({ value: new Set() });
updateRootSuite(baseFullConfig, new TeleSuite('', 'root'), undefined);
updateRootSuite(baseFullConfig, new TeleSuite('', 'root'), [], undefined);
refreshRootSuite(true).then(() => {
setIsLoading(false);
});
};
}, []);
React.useEffect(() => {
inputRef.current?.focus();
reloadTests();
}, []);
}, [reloadTests]);
updateRootSuite = (config: FullConfig, rootSuite: Suite, newProgress: Progress | undefined) => {
updateRootSuite = React.useCallback((config: FullConfig, rootSuite: Suite, loadErrors: TestError[], 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))
@ -108,13 +110,13 @@ export const UIModeView: React.FC<{}> = ({
if (!selectedProjects && projectFilters.size && ![...projectFilters.values()].includes(true))
projectFilters.set(projectFilters.entries().next().value[0], true);
setTestModel({ config, rootSuite });
setTestModel({ config, rootSuite, loadErrors });
setProjectFilters(new Map(projectFilters));
if (runningState && newProgress)
setProgress({ ...newProgress, total: runningState.testIds.size });
else if (!newProgress)
setProgress(undefined);
};
}, [projectFilters, runningState]);
const runTests = React.useCallback((mode: 'queue-if-busy' | 'bounce-if-busy', testIds: Set<string>) => {
if (mode === 'bounce-if-busy' && runningState)
@ -300,7 +302,7 @@ const TestList: React.FC<{
statusFilters: Map<string, boolean>,
projectFilters: Map<string, boolean>,
filterText: string,
testModel: { rootSuite: Suite | undefined, config: FullConfig | undefined },
testModel: TestModel,
runTests: (mode: 'bounce-if-busy' | 'queue-if-busy', testIds: Set<string>) => void,
runningState?: { testIds: Set<string>, itemSelectedByUser?: boolean },
watchAll: boolean,
@ -308,7 +310,7 @@ const TestList: React.FC<{
setWatchedTreeIds: (ids: { value: Set<string> }) => void,
isLoading?: boolean,
setVisibleTestIds: (testIds: Set<string>) => void,
onItemSelected: (item: { testCase?: TestCase, location?: Location }) => void,
onItemSelected: (item: { testCase?: TestCase, testFile?: SourceLocation }) => void,
requestedCollapseAllCount: number,
}> = ({ statusFilters, projectFilters, filterText, testModel, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, setVisibleTestIds, requestedCollapseAllCount }) => {
const [treeState, setTreeState] = React.useState<TreeState>({ expandedItems: new Map() });
@ -317,7 +319,7 @@ const TestList: React.FC<{
// Build the test tree.
const { rootItem, treeItemMap, fileNames } = React.useMemo(() => {
let rootItem = createTree(testModel.rootSuite, projectFilters);
let rootItem = createTree(testModel.rootSuite, testModel.loadErrors, projectFilters);
filterTree(rootItem, filterText, statusFilters, runningState?.testIds);
sortAndPropagateStatus(rootItem);
rootItem = shortenRoot(rootItem);
@ -375,15 +377,25 @@ const TestList: React.FC<{
// Compute selected item.
const { selectedTreeItem } = React.useMemo(() => {
const selectedTreeItem = selectedTreeItemId ? treeItemMap.get(selectedTreeItemId) : undefined;
const location = selectedTreeItem?.location;
let testFile: SourceLocation | undefined;
if (selectedTreeItem) {
testFile = {
file: selectedTreeItem.location.file,
line: selectedTreeItem.location.line,
source: {
errors: testModel.loadErrors.filter(e => e.location?.file === selectedTreeItem.location.file).map(e => ({ line: e.location!.line, message: e.message! })),
content: undefined,
}
};
}
let selectedTest: TestCase | undefined;
if (selectedTreeItem?.kind === 'test')
selectedTest = selectedTreeItem.test;
else if (selectedTreeItem?.kind === 'case' && selectedTreeItem.tests.length === 1)
selectedTest = selectedTreeItem.tests[0];
onItemSelected({ testCase: selectedTest, location });
onItemSelected({ testCase: selectedTest, testFile });
return { selectedTreeItem };
}, [onItemSelected, selectedTreeItemId, treeItemMap]);
}, [onItemSelected, selectedTreeItemId, testModel, treeItemMap]);
// Update watch all.
React.useEffect(() => {
@ -471,12 +483,13 @@ const TestList: React.FC<{
runningState.itemSelectedByUser = true;
setSelectedTreeItemId(treeItem.id);
}}
isError={treeItem => treeItem.kind === 'group' ? treeItem.hasLoadErrors : false}
autoExpandDepth={filterText ? 5 : 1}
noItemsMessage={isLoading ? 'Loading\u2026' : 'No tests'} />;
};
const TraceView: React.FC<{
item: { location?: Location, testCase?: TestCase },
item: { testFile?: SourceLocation, testCase?: TestCase },
rootDir?: string,
}> = ({ item, rootDir }) => {
const [model, setModel] = React.useState<MultiTraceModel | undefined>();
@ -543,7 +556,7 @@ const TraceView: React.FC<{
rootDir={rootDir}
initialSelection={initialSelection}
onSelectionChanged={onSelectionChanged}
defaultSourceLocation={item.location} />;
fallbackLocation={item.testFile} />;
};
declare global {
@ -555,15 +568,15 @@ declare global {
let receiver: TeleReporterReceiver | undefined;
let throttleTimer: NodeJS.Timeout | undefined;
let throttleData: { config: FullConfig, rootSuite: Suite, progress: Progress } | undefined;
let throttleData: { config: FullConfig, rootSuite: Suite, loadErrors: TestError[], progress: Progress } | undefined;
const throttledAction = () => {
clearTimeout(throttleTimer);
throttleTimer = undefined;
updateRootSuite(throttleData!.config, throttleData!.rootSuite, throttleData!.progress);
updateRootSuite(throttleData!.config, throttleData!.rootSuite, throttleData!.loadErrors, throttleData!.progress);
};
const throttleUpdateRootSuite = (config: FullConfig, rootSuite: Suite, progress: Progress, immediate = false) => {
throttleData = { config, rootSuite, progress };
const throttleUpdateRootSuite = (config: FullConfig, rootSuite: Suite, loadErrors: TestError[], progress: Progress, immediate = false) => {
throttleData = { config, rootSuite, loadErrors, progress };
if (immediate)
throttledAction();
else if (!throttleTimer)
@ -575,6 +588,7 @@ const refreshRootSuite = (eraseResults: boolean): Promise<void> => {
return sendMessage('list', {});
let rootSuite: Suite;
let loadErrors: TestError[];
const progress: Progress = {
passed: 0,
failed: 0,
@ -583,21 +597,23 @@ const refreshRootSuite = (eraseResults: boolean): Promise<void> => {
let config: FullConfig;
receiver = new TeleReporterReceiver(pathSeparator, {
onBegin: (c: FullConfig, suite: Suite) => {
if (!rootSuite)
if (!rootSuite) {
rootSuite = suite;
loadErrors = [];
}
config = c;
progress.passed = 0;
progress.failed = 0;
progress.skipped = 0;
throttleUpdateRootSuite(config, rootSuite, progress, true);
throttleUpdateRootSuite(config, rootSuite, loadErrors, progress, true);
},
onEnd: () => {
throttleUpdateRootSuite(config, rootSuite, progress, true);
throttleUpdateRootSuite(config, rootSuite, loadErrors, progress, true);
},
onTestBegin: () => {
throttleUpdateRootSuite(config, rootSuite, progress);
throttleUpdateRootSuite(config, rootSuite, loadErrors, progress);
},
onTestEnd: (test: TestCase) => {
@ -607,11 +623,13 @@ const refreshRootSuite = (eraseResults: boolean): Promise<void> => {
++progress.failed;
else
++progress.passed;
throttleUpdateRootSuite(config, rootSuite, progress);
throttleUpdateRootSuite(config, rootSuite, loadErrors, progress);
},
onError: (error: TestError) => {
xtermDataSource.write((error.stack || error.value || '') + '\n');
loadErrors.push(error);
throttleUpdateRootSuite(config, rootSuite, loadErrors, progress);
},
});
receiver._setClearPreviousResultsWhenTestBegins();
@ -708,6 +726,7 @@ type TreeItemBase = {
type GroupItem = TreeItemBase & {
kind: 'group';
subKind: 'folder' | 'file' | 'describe';
hasLoadErrors: boolean;
children: (TestCaseItem | GroupItem)[];
};
@ -743,13 +762,14 @@ function getFileItem(rootItem: GroupItem, filePath: string[], isFile: boolean, f
parent: parentFileItem,
children: [],
status: 'none',
hasLoadErrors: false,
};
parentFileItem.children.push(fileItem);
fileItems.set(fileName, fileItem);
return fileItem;
}
function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, boolean>): GroupItem {
function createTree(rootSuite: Suite | undefined, loadErrors: TestError[], projectFilters: Map<string, boolean>): GroupItem {
const filterProjects = [...projectFilters.values()].some(Boolean);
const rootItem: GroupItem = {
kind: 'group',
@ -761,6 +781,7 @@ function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, bo
parent: undefined,
children: [],
status: 'none',
hasLoadErrors: false,
};
const visitSuite = (projectName: string, parentSuite: Suite, parentGroup: GroupItem) => {
@ -778,6 +799,7 @@ function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, bo
parent: parentGroup,
children: [],
status: 'none',
hasLoadErrors: false,
};
parentGroup.children.push(group);
}
@ -842,6 +864,12 @@ function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, bo
const fileItem = getFileItem(rootItem, fileSuite.location!.file.split(pathSeparator), true, fileMap);
visitSuite(projectSuite.title, fileSuite, fileItem);
}
for (const loadError of loadErrors) {
if (!loadError.location)
continue;
const fileItem = getFileItem(rootItem, loadError.location.file.split(pathSeparator), true, fileMap);
fileItem.hasLoadErrors = true;
}
}
return rootItem;
}
@ -869,7 +897,7 @@ function filterTree(rootItem: GroupItem, filterText: string, statusFilters: Map<
newChildren.push(child);
} else {
visit(child);
if (child.children.length)
if (child.children.length || child.hasLoadErrors)
newChildren.push(child);
}
}

View File

@ -30,7 +30,6 @@ import type { TabbedPaneTabModel } from '@web/components/tabbedPane';
import { Timeline } from './timeline';
import './workbench.css';
import { MetadataView } from './metadataView';
import type { Location } from '../../../playwright-test/types/testReporter';
export const Workbench: React.FunctionComponent<{
model?: MultiTraceModel,
@ -38,10 +37,10 @@ export const Workbench: React.FunctionComponent<{
hideStackFrames?: boolean,
showSourcesFirst?: boolean,
rootDir?: string,
defaultSourceLocation?: Location,
fallbackLocation?: modelUtil.SourceLocation,
initialSelection?: ActionTraceEvent,
onSelectionChanged?: (action: ActionTraceEvent) => void,
}> = ({ model, hideTimelineBars, hideStackFrames, showSourcesFirst, rootDir, defaultSourceLocation, initialSelection, onSelectionChanged }) => {
}> = ({ model, hideTimelineBars, hideStackFrames, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged }) => {
const [selectedAction, setSelectedAction] = React.useState<ActionTraceEvent | undefined>(undefined);
const [highlightedAction, setHighlightedAction] = React.useState<ActionTraceEvent | undefined>();
const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions');
@ -85,7 +84,7 @@ export const Workbench: React.FunctionComponent<{
sources={sources}
hideStackFrames={hideStackFrames}
rootDir={rootDir}
fallbackLocation={defaultSourceLocation} />
fallbackLocation={fallbackLocation} />
};
const consoleTab: TabbedPaneTabModel = {
id: 'console',

View File

@ -61,10 +61,6 @@
background-color: transparent !important;
}
.list-view-content:focus .list-view-entry.error.selected {
outline: 1px solid var(--vscode-inputValidation-errorBorder);
}
.list-view-content:focus .list-view-entry.selected .codicon {
color: var(--vscode-list-activeSelectionForeground) !important;
}
@ -78,5 +74,4 @@
.list-view-entry.error {
color: var(--vscode-list-errorForeground);
background-color: var(--vscode-inputValidation-errorBackground);
}

View File

@ -64,3 +64,39 @@ test('should show selected test in sources', async ({ runUITest }) => {
page.locator('.CodeMirror .source-line-running'),
).toHaveText(`3 test('third', () => {});`);
});
test('should show syntax errors in file', async ({ runUITest }) => {
const { page } = await runUITest({
'a.test.ts': `
import { test } from '@playwright/test';
const a = 1;
a = 2;
test('first', () => {});
test('second', () => {});
`,
'b.test.ts': `
import { test } from '@playwright/test';
test('third', () => {});
`,
});
await expect.poll(dumpTestTree(page)).toBe(`
a.test.ts
b.test.ts
third
`);
await page.getByTestId('test-tree').getByText('a.test.ts').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 a = 2;`);
await expect(
page.locator('.CodeMirror-linewidget')
).toHaveText([
'            ',
'Assignment to constant variable.'
]);
});