chore: align test tree with vscode (#29864)
This commit is contained in:
parent
23bfeec5c7
commit
94348bb3c5
|
|
@ -39,28 +39,31 @@ export type TestCaseItem = TreeItemBase & {
|
||||||
kind: 'case',
|
kind: 'case',
|
||||||
tests: reporterTypes.TestCase[];
|
tests: reporterTypes.TestCase[];
|
||||||
children: TestItem[];
|
children: TestItem[];
|
||||||
|
test: reporterTypes.TestCase | undefined;
|
||||||
|
project: reporterTypes.FullProject | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TestItem = TreeItemBase & {
|
export type TestItem = TreeItemBase & {
|
||||||
kind: 'test',
|
kind: 'test',
|
||||||
test: reporterTypes.TestCase;
|
test: reporterTypes.TestCase;
|
||||||
project: string;
|
project: reporterTypes.FullProject;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TreeItem = GroupItem | TestCaseItem | TestItem;
|
export type TreeItem = GroupItem | TestCaseItem | TestItem;
|
||||||
|
|
||||||
export class TestTree {
|
export class TestTree {
|
||||||
rootItem: GroupItem;
|
rootItem: GroupItem;
|
||||||
readonly treeItemMap = new Map<string, TreeItem>();
|
private _treeItemById = new Map<string, TreeItem>();
|
||||||
readonly visibleTestIds = new Set<string>();
|
private _treeItemByTestId = new Map<string, TestItem | TestCaseItem>();
|
||||||
readonly fileNames = new Set<string>();
|
readonly pathSeparator: string;
|
||||||
|
|
||||||
constructor(rootSuite: reporterTypes.Suite | undefined, loadErrors: reporterTypes.TestError[], projectFilters: Map<string, boolean>) {
|
constructor(rootFolder: string, rootSuite: reporterTypes.Suite | undefined, loadErrors: reporterTypes.TestError[], projectFilters: Map<string, boolean> | undefined, pathSeparator: string) {
|
||||||
const filterProjects = [...projectFilters.values()].some(Boolean);
|
const filterProjects = projectFilters && [...projectFilters.values()].some(Boolean);
|
||||||
|
this.pathSeparator = pathSeparator;
|
||||||
this.rootItem = {
|
this.rootItem = {
|
||||||
kind: 'group',
|
kind: 'group',
|
||||||
subKind: 'folder',
|
subKind: 'folder',
|
||||||
id: 'root',
|
id: rootFolder,
|
||||||
title: '',
|
title: '',
|
||||||
location: { file: '', line: 0, column: 0 },
|
location: { file: '', line: 0, column: 0 },
|
||||||
duration: 0,
|
duration: 0,
|
||||||
|
|
@ -69,8 +72,9 @@ export class TestTree {
|
||||||
status: 'none',
|
status: 'none',
|
||||||
hasLoadErrors: false,
|
hasLoadErrors: false,
|
||||||
};
|
};
|
||||||
|
this._treeItemById.set(rootFolder, this.rootItem);
|
||||||
|
|
||||||
const visitSuite = (projectName: string, parentSuite: reporterTypes.Suite, parentGroup: GroupItem) => {
|
const visitSuite = (project: reporterTypes.FullProject, parentSuite: reporterTypes.Suite, parentGroup: GroupItem) => {
|
||||||
for (const suite of parentSuite.suites) {
|
for (const suite of parentSuite.suites) {
|
||||||
const title = suite.title || '<anonymous>';
|
const title = suite.title || '<anonymous>';
|
||||||
let group = parentGroup.children.find(item => item.kind === 'group' && item.title === title) as GroupItem | undefined;
|
let group = parentGroup.children.find(item => item.kind === 'group' && item.title === title) as GroupItem | undefined;
|
||||||
|
|
@ -87,9 +91,9 @@ export class TestTree {
|
||||||
status: 'none',
|
status: 'none',
|
||||||
hasLoadErrors: false,
|
hasLoadErrors: false,
|
||||||
};
|
};
|
||||||
parentGroup.children.push(group);
|
this._addChild(parentGroup, group);
|
||||||
}
|
}
|
||||||
visitSuite(projectName, suite, group);
|
visitSuite(project, suite, group);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const test of parentSuite.tests) {
|
for (const test of parentSuite.tests) {
|
||||||
|
|
@ -106,8 +110,10 @@ export class TestTree {
|
||||||
location: test.location,
|
location: test.location,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
status: 'none',
|
status: 'none',
|
||||||
|
project: undefined,
|
||||||
|
test: undefined,
|
||||||
};
|
};
|
||||||
parentGroup.children.push(testCaseItem);
|
this._addChild(parentGroup, testCaseItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = test.results[0];
|
const result = test.results[0];
|
||||||
|
|
@ -126,40 +132,47 @@ export class TestTree {
|
||||||
status = 'passed';
|
status = 'passed';
|
||||||
|
|
||||||
testCaseItem.tests.push(test);
|
testCaseItem.tests.push(test);
|
||||||
testCaseItem.children.push({
|
const testItem: TestItem = {
|
||||||
kind: 'test',
|
kind: 'test',
|
||||||
id: test.id,
|
id: test.id,
|
||||||
title: projectName,
|
title: project.name,
|
||||||
location: test.location!,
|
location: test.location!,
|
||||||
test,
|
test,
|
||||||
parent: testCaseItem,
|
parent: testCaseItem,
|
||||||
children: [],
|
children: [],
|
||||||
status,
|
status,
|
||||||
duration: test.results.length ? Math.max(0, test.results[0].duration) : 0,
|
duration: test.results.length ? Math.max(0, test.results[0].duration) : 0,
|
||||||
project: projectName,
|
project,
|
||||||
});
|
};
|
||||||
|
this._addChild(testCaseItem, testItem);
|
||||||
|
this._treeItemByTestId.set(test.id, testItem);
|
||||||
testCaseItem.duration = (testCaseItem.children as TestItem[]).reduce((a, b) => a + b.duration, 0);
|
testCaseItem.duration = (testCaseItem.children as TestItem[]).reduce((a, b) => a + b.duration, 0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fileMap = new Map<string, GroupItem>();
|
|
||||||
for (const projectSuite of rootSuite?.suites || []) {
|
for (const projectSuite of rootSuite?.suites || []) {
|
||||||
if (filterProjects && !projectFilters.get(projectSuite.title))
|
if (filterProjects && !projectFilters.get(projectSuite.title))
|
||||||
continue;
|
continue;
|
||||||
for (const fileSuite of projectSuite.suites) {
|
for (const fileSuite of projectSuite.suites) {
|
||||||
const fileItem = this._fileItem(fileSuite.location!.file.split(pathSeparator), true, fileMap);
|
const fileItem = this._fileItem(fileSuite.location!.file.split(pathSeparator), true);
|
||||||
visitSuite(projectSuite.title, fileSuite, fileItem);
|
visitSuite(projectSuite.project()!, fileSuite, fileItem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const loadError of loadErrors) {
|
for (const loadError of loadErrors) {
|
||||||
if (!loadError.location)
|
if (!loadError.location)
|
||||||
continue;
|
continue;
|
||||||
const fileItem = this._fileItem(loadError.location.file.split(pathSeparator), true, fileMap);
|
const fileItem = this._fileItem(loadError.location.file.split(pathSeparator), true);
|
||||||
fileItem.hasLoadErrors = true;
|
fileItem.hasLoadErrors = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _addChild(parent: TreeItem, child: TreeItem) {
|
||||||
|
parent.children.push(child);
|
||||||
|
child.parent = parent;
|
||||||
|
this._treeItemById.set(child.id, child);
|
||||||
|
}
|
||||||
|
|
||||||
filterTree(filterText: string, statusFilters: Map<string, boolean>, runningTestIds: Set<string> | undefined) {
|
filterTree(filterText: string, statusFilters: Map<string, boolean>, runningTestIds: Set<string> | undefined) {
|
||||||
const tokens = filterText.trim().toLowerCase().split(' ');
|
const tokens = filterText.trim().toLowerCase().split(' ');
|
||||||
const filtersStatuses = [...statusFilters.values()].some(Boolean);
|
const filtersStatuses = [...statusFilters.values()].some(Boolean);
|
||||||
|
|
@ -192,14 +205,14 @@ export class TestTree {
|
||||||
visit(this.rootItem);
|
visit(this.rootItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _fileItem(filePath: string[], isFile: boolean, fileItems: Map<string, GroupItem>): GroupItem {
|
private _fileItem(filePath: string[], isFile: boolean): GroupItem {
|
||||||
if (filePath.length === 0)
|
if (filePath.length === 0)
|
||||||
return this.rootItem;
|
return this.rootItem;
|
||||||
const fileName = filePath.join(pathSeparator);
|
const fileName = filePath.join(this.pathSeparator);
|
||||||
const existingFileItem = fileItems.get(fileName);
|
const existingFileItem = this._treeItemById.get(fileName);
|
||||||
if (existingFileItem)
|
if (existingFileItem)
|
||||||
return existingFileItem;
|
return existingFileItem as GroupItem;
|
||||||
const parentFileItem = this._fileItem(filePath.slice(0, filePath.length - 1), false, fileItems);
|
const parentFileItem = this._fileItem(filePath.slice(0, filePath.length - 1), false);
|
||||||
const fileItem: GroupItem = {
|
const fileItem: GroupItem = {
|
||||||
kind: 'group',
|
kind: 'group',
|
||||||
subKind: isFile ? 'file' : 'folder',
|
subKind: isFile ? 'file' : 'folder',
|
||||||
|
|
@ -212,8 +225,7 @@ export class TestTree {
|
||||||
status: 'none',
|
status: 'none',
|
||||||
hasLoadErrors: false,
|
hasLoadErrors: false,
|
||||||
};
|
};
|
||||||
parentFileItem.children.push(fileItem);
|
this._addChild(parentFileItem, fileItem);
|
||||||
fileItems.set(fileName, fileItem);
|
|
||||||
return fileItem;
|
return fileItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -221,12 +233,16 @@ export class TestTree {
|
||||||
sortAndPropagateStatus(this.rootItem);
|
sortAndPropagateStatus(this.rootItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
hideOnlyTests() {
|
flattenForSingleProject() {
|
||||||
const visit = (treeItem: TreeItem) => {
|
const visit = (treeItem: TreeItem) => {
|
||||||
if (treeItem.kind === 'case' && treeItem.children.length === 1)
|
if (treeItem.kind === 'case' && treeItem.children.length === 1) {
|
||||||
|
treeItem.project = treeItem.children[0].project;
|
||||||
|
treeItem.test = treeItem.children[0].test;
|
||||||
treeItem.children = [];
|
treeItem.children = [];
|
||||||
else
|
this._treeItemByTestId.set(treeItem.test.id, treeItem);
|
||||||
|
} else {
|
||||||
treeItem.children.forEach(visit);
|
treeItem.children.forEach(visit);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
visit(this.rootItem);
|
visit(this.rootItem);
|
||||||
}
|
}
|
||||||
|
|
@ -239,16 +255,41 @@ export class TestTree {
|
||||||
this.rootItem = shortRoot;
|
this.rootItem = shortRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
indexTree() {
|
testIds(): Set<string> {
|
||||||
|
const result = new Set<string>();
|
||||||
const visit = (treeItem: TreeItem) => {
|
const visit = (treeItem: TreeItem) => {
|
||||||
if (treeItem.kind === 'group' && treeItem.location.file)
|
|
||||||
this.fileNames.add(treeItem.location.file);
|
|
||||||
if (treeItem.kind === 'case')
|
if (treeItem.kind === 'case')
|
||||||
treeItem.tests.forEach(t => this.visibleTestIds.add(t.id));
|
treeItem.tests.forEach(t => result.add(t.id));
|
||||||
treeItem.children.forEach(visit);
|
treeItem.children.forEach(visit);
|
||||||
this.treeItemMap.set(treeItem.id, treeItem);
|
|
||||||
};
|
};
|
||||||
visit(this.rootItem);
|
visit(this.rootItem);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileNames(): string[] {
|
||||||
|
const result = new Set<string>();
|
||||||
|
const visit = (treeItem: TreeItem) => {
|
||||||
|
if (treeItem.kind === 'group' && treeItem.subKind === 'file')
|
||||||
|
result.add(treeItem.id);
|
||||||
|
else
|
||||||
|
treeItem.children.forEach(visit);
|
||||||
|
};
|
||||||
|
visit(this.rootItem);
|
||||||
|
return [...result];
|
||||||
|
}
|
||||||
|
|
||||||
|
flatTreeItems(): TreeItem[] {
|
||||||
|
const result: TreeItem[] = [];
|
||||||
|
const visit = (treeItem: TreeItem) => {
|
||||||
|
result.push(treeItem);
|
||||||
|
treeItem.children.forEach(visit);
|
||||||
|
};
|
||||||
|
visit(this.rootItem);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
treeItemById(id: string): TreeItem | undefined {
|
||||||
|
return this._treeItemById.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
collectTestIds(treeItem?: TreeItem): Set<string> {
|
collectTestIds(treeItem?: TreeItem): Set<string> {
|
||||||
|
|
@ -312,5 +353,4 @@ export function sortAndPropagateStatus(treeItem: TreeItem) {
|
||||||
treeItem.status = 'passed';
|
treeItem.status = 'passed';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pathSeparator = navigator.userAgent.toLowerCase().includes('windows') ? '\\' : '/';
|
|
||||||
export const statusEx = Symbol('statusEx');
|
export const statusEx = Symbol('statusEx');
|
||||||
|
|
|
||||||
2
packages/playwright/types/testReporter.d.ts
vendored
2
packages/playwright/types/testReporter.d.ts
vendored
|
|
@ -16,7 +16,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { FullConfig, FullProject, TestStatus, Metadata } from './test';
|
import type { FullConfig, FullProject, TestStatus, Metadata } from './test';
|
||||||
export type { FullConfig, TestStatus } from './test';
|
export type { FullConfig, TestStatus, FullProject } from './test';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `Suite` is a group of tests. All tests in Playwright Test form the following hierarchy:
|
* `Suite` is a group of tests. All tests in Playwright Test form the following hierarchy:
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { TeleReporterReceiver } from '@testIsomorphic/teleReceiver';
|
import { TeleReporterReceiver } from '@testIsomorphic/teleReceiver';
|
||||||
import { pathSeparator, statusEx } from '@testIsomorphic/testTree';
|
import { statusEx } from '@testIsomorphic/testTree';
|
||||||
import type { ReporterV2 } from 'playwright/src/reporters/reporterV2';
|
import type { ReporterV2 } from 'playwright/src/reporters/reporterV2';
|
||||||
import type * as reporterTypes from 'playwright/types/testReporter';
|
import type * as reporterTypes from 'playwright/types/testReporter';
|
||||||
|
|
||||||
|
|
@ -28,7 +28,8 @@ export type Progress = {
|
||||||
|
|
||||||
export type TeleSuiteUpdaterOptions = {
|
export type TeleSuiteUpdaterOptions = {
|
||||||
onUpdate: (source: TeleSuiteUpdater, force?: boolean) => void,
|
onUpdate: (source: TeleSuiteUpdater, force?: boolean) => void,
|
||||||
onError?: (error: reporterTypes.TestError) => void
|
onError?: (error: reporterTypes.TestError) => void;
|
||||||
|
pathSeparator: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class TeleSuiteUpdater {
|
export class TeleSuiteUpdater {
|
||||||
|
|
@ -51,7 +52,7 @@ export class TeleSuiteUpdater {
|
||||||
this._receiver = new TeleReporterReceiver(this._createReporter(), {
|
this._receiver = new TeleReporterReceiver(this._createReporter(), {
|
||||||
mergeProjects: true,
|
mergeProjects: true,
|
||||||
mergeTestCases: true,
|
mergeTestCases: true,
|
||||||
resolvePath: (rootDir, relativePath) => rootDir + pathSeparator + relativePath,
|
resolvePath: (rootDir, relativePath) => rootDir + options.pathSeparator + relativePath,
|
||||||
clearPreviousResultsWhenTestBegins: true,
|
clearPreviousResultsWhenTestBegins: true,
|
||||||
});
|
});
|
||||||
this._options = options;
|
this._options = options;
|
||||||
|
|
@ -75,7 +76,7 @@ export class TeleSuiteUpdater {
|
||||||
}, {
|
}, {
|
||||||
mergeProjects: true,
|
mergeProjects: true,
|
||||||
mergeTestCases: false,
|
mergeTestCases: false,
|
||||||
resolvePath: (rootDir, relativePath) => rootDir + pathSeparator + relativePath,
|
resolvePath: (rootDir, relativePath) => rootDir + this._options.pathSeparator + relativePath,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -379,13 +379,12 @@ const TestList: React.FC<{
|
||||||
|
|
||||||
// Build the test tree.
|
// Build the test tree.
|
||||||
const { testTree } = React.useMemo(() => {
|
const { testTree } = React.useMemo(() => {
|
||||||
const testTree = new TestTree(testModel.rootSuite, testModel.loadErrors, projectFilters);
|
const testTree = new TestTree('', testModel.rootSuite, testModel.loadErrors, projectFilters, pathSeparator);
|
||||||
testTree.filterTree(filterText, statusFilters, runningState?.testIds);
|
testTree.filterTree(filterText, statusFilters, runningState?.testIds);
|
||||||
testTree.sortAndPropagateStatus();
|
testTree.sortAndPropagateStatus();
|
||||||
testTree.shortenRoot();
|
testTree.shortenRoot();
|
||||||
testTree.hideOnlyTests();
|
testTree.flattenForSingleProject();
|
||||||
testTree.indexTree();
|
setVisibleTestIds(testTree.testIds());
|
||||||
setVisibleTestIds(testTree.visibleTestIds);
|
|
||||||
return { testTree };
|
return { testTree };
|
||||||
}, [filterText, testModel, statusFilters, projectFilters, setVisibleTestIds, runningState]);
|
}, [filterText, testModel, statusFilters, projectFilters, setVisibleTestIds, runningState]);
|
||||||
|
|
||||||
|
|
@ -394,8 +393,8 @@ const TestList: React.FC<{
|
||||||
// If collapse was requested, clear the expanded items and return w/o selected item.
|
// If collapse was requested, clear the expanded items and return w/o selected item.
|
||||||
if (collapseAllCount !== requestedCollapseAllCount) {
|
if (collapseAllCount !== requestedCollapseAllCount) {
|
||||||
treeState.expandedItems.clear();
|
treeState.expandedItems.clear();
|
||||||
for (const item of testTree.treeItemMap.keys())
|
for (const item of testTree.flatTreeItems())
|
||||||
treeState.expandedItems.set(item, false);
|
treeState.expandedItems.set(item.id, false);
|
||||||
setCollapseAllCount(requestedCollapseAllCount);
|
setCollapseAllCount(requestedCollapseAllCount);
|
||||||
setSelectedTreeItemId(undefined);
|
setSelectedTreeItemId(undefined);
|
||||||
setTreeState({ ...treeState });
|
setTreeState({ ...treeState });
|
||||||
|
|
@ -424,7 +423,7 @@ const TestList: React.FC<{
|
||||||
|
|
||||||
// Compute selected item.
|
// Compute selected item.
|
||||||
const { selectedTreeItem } = React.useMemo(() => {
|
const { selectedTreeItem } = React.useMemo(() => {
|
||||||
const selectedTreeItem = selectedTreeItemId ? testTree.treeItemMap.get(selectedTreeItemId) : undefined;
|
const selectedTreeItem = selectedTreeItemId ? testTree.treeItemById(selectedTreeItemId) : undefined;
|
||||||
let testFile: SourceLocation | undefined;
|
let testFile: SourceLocation | undefined;
|
||||||
if (selectedTreeItem) {
|
if (selectedTreeItem) {
|
||||||
testFile = {
|
testFile = {
|
||||||
|
|
@ -450,11 +449,11 @@ const TestList: React.FC<{
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
return;
|
return;
|
||||||
if (watchAll) {
|
if (watchAll) {
|
||||||
sendMessageNoReply('watch', { fileNames: [...testTree.fileNames] });
|
sendMessageNoReply('watch', { fileNames: testTree.fileNames() });
|
||||||
} else {
|
} else {
|
||||||
const fileNames = new Set<string>();
|
const fileNames = new Set<string>();
|
||||||
for (const itemId of watchedTreeIds.value) {
|
for (const itemId of watchedTreeIds.value) {
|
||||||
const treeItem = testTree.treeItemMap.get(itemId);
|
const treeItem = testTree.treeItemById(itemId);
|
||||||
const fileName = treeItem?.location.file;
|
const fileName = treeItem?.location.file;
|
||||||
if (fileName)
|
if (fileName)
|
||||||
fileNames.add(fileName);
|
fileNames.add(fileName);
|
||||||
|
|
@ -482,7 +481,7 @@ const TestList: React.FC<{
|
||||||
visit(testTree.rootItem);
|
visit(testTree.rootItem);
|
||||||
} else {
|
} else {
|
||||||
for (const treeId of watchedTreeIds.value) {
|
for (const treeId of watchedTreeIds.value) {
|
||||||
const treeItem = testTree.treeItemMap.get(treeId);
|
const treeItem = testTree.treeItemById(treeId);
|
||||||
const fileName = treeItem?.location.file;
|
const fileName = treeItem?.location.file;
|
||||||
if (fileName && set.has(fileName))
|
if (fileName && set.has(fileName))
|
||||||
testIds.push(...testTree.collectTestIds(treeItem));
|
testIds.push(...testTree.collectTestIds(treeItem));
|
||||||
|
|
@ -624,6 +623,7 @@ const refreshRootSuite = (): Promise<void> => {
|
||||||
onError: error => {
|
onError: error => {
|
||||||
xtermDataSource.write((error.stack || error.value || '') + '\n');
|
xtermDataSource.write((error.stack || error.value || '') + '\n');
|
||||||
},
|
},
|
||||||
|
pathSeparator,
|
||||||
});
|
});
|
||||||
return sendMessage('list', {});
|
return sendMessage('list', {});
|
||||||
};
|
};
|
||||||
|
|
@ -681,3 +681,5 @@ async function loadSingleTraceFile(url: string): Promise<MultiTraceModel> {
|
||||||
const contextEntries = await response.json() as ContextEntry[];
|
const contextEntries = await response.json() as ContextEntry[];
|
||||||
return new MultiTraceModel(contextEntries);
|
return new MultiTraceModel(contextEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const pathSeparator = navigator.userAgent.toLowerCase().includes('windows') ? '\\' : '/';
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { FullConfig, FullProject, TestStatus, Metadata } from './test';
|
import type { FullConfig, FullProject, TestStatus, Metadata } from './test';
|
||||||
export type { FullConfig, TestStatus } from './test';
|
export type { FullConfig, TestStatus, FullProject } from './test';
|
||||||
|
|
||||||
export interface Suite {
|
export interface Suite {
|
||||||
project(): FullProject | undefined;
|
project(): FullProject | undefined;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue