feat(html): live filtering, opt-out from auto-open (#9889)

This commit is contained in:
Pavel Feldman 2021-10-29 15:24:08 -08:00 committed by GitHub
parent 49337890d2
commit 8991bbde33
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 137 additions and 104 deletions

View file

@ -238,6 +238,32 @@ HTML reporter produces a self-contained folder that contains report for the test
npx playwright test --reporter=html npx playwright test --reporter=html
``` ```
By default, HTML report is opened automatically if some of the tests failed. You can control this behavior via the
`open` property in the Playwright config. The possible values for that property are `always`, `never` and `on-failure`
(default).
```js js-flavor=js
// playwright.config.js
// @ts-check
/** @type {import('@playwright/test').PlaywrightTestConfig} */
const config = {
reporter: [ ['html', { open: 'never' }] ],
};
module.exports = config;
```
```js js-flavor=ts
// playwright.config.ts
import { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
reporter: [ ['html', { open: 'never' }] ],
};
export default config;
```
By default, report is written into the `playwright-report` folder in the current working directory. One can override By default, report is written into the `playwright-report` folder in the current working directory. One can override
that location using the `PLAYWRIGHT_HTML_REPORT` environment variable or a reporter configuration. that location using the `PLAYWRIGHT_HTML_REPORT` environment variable or a reporter configuration.

View file

@ -32,6 +32,10 @@ html, body {
overscroll-behavior-x: none; overscroll-behavior-x: none;
} }
body {
width: 100vw;
}
body { body {
overflow: auto; overflow: auto;
} }
@ -349,6 +353,7 @@ a.no-decorations {
} }
.color-text-warning { .color-text-warning {
color: var(--color-checks-step-warning-text) !important;
} }
.color-fg-muted { .color-fg-muted {
@ -377,36 +382,34 @@ a.no-decorations {
@media(prefers-color-scheme: light) { @media(prefers-color-scheme: light) {
.label-color-0 { .label-color-0 {
background-color: var(--color-scale-blue-4); background-color: var(--color-scale-blue-0);
color: var(--color-scale-white); color: var(--color-scale-blue-6);
border: 1px solid var(--color-scale-blue-4);
} }
.label-color-1 { .label-color-1 {
background-color: var(--color-scale-green-4); background-color: var(--color-scale-yellow-0);
color: var(--color-scale-white); color: var(--color-scale-yellow-6);
border: 1px solid var(--color-scale-yellow-4);
} }
.label-color-2 { .label-color-2 {
background-color: var(--color-scale-yellow-4); background-color: var(--color-scale-purple-0);
color: var(--color-scale-white); color: var(--color-scale-purple-6);
border: 1px solid var(--color-scale-purple-4);
} }
.label-color-3 { .label-color-3 {
background-color: var(--color-scale-orange-4); background-color: var(--color-scale-pink-0);
color: var(--color-scale-white); color: var(--color-scale-pink-6);
border: 1px solid var(--color-scale-pink-4);
} }
.label-color-4 { .label-color-4 {
background-color: var(--color-scale-red-4); background-color: var(--color-scale-coral-0);
color: var(--color-scale-white); color: var(--color-scale-coral-6);
border: 1px solid var(--color-scale-coral-4);
} }
.label-color-5 { .label-color-5 {
background-color: var(--color-scale-purple-4); background-color: var(--color-scale-orange-0);
color: var(--color-scale-white); color: var(--color-scale-orange-6);
} border: 1px solid var(--color-scale-orange-4);
.label-color-6 {
background-color: var(--color-scale-pink-4);
color: var(--color-scale-white);
}
.label-color-7 {
background-color: var(--color-scale-coral-4);
color: var(--color-scale-white);
} }
} }
@ -417,40 +420,30 @@ a.no-decorations {
border: 1px solid var(--color-scale-blue-4); border: 1px solid var(--color-scale-blue-4);
} }
.label-color-1 { .label-color-1 {
background-color: var(--color-scale-green-9);
color: var(--color-scale-green-2);
border: 1px solid var(--color-scale-green-4);
}
.label-color-2 {
background-color: var(--color-scale-yellow-9); background-color: var(--color-scale-yellow-9);
color: var(--color-scale-yellow-2); color: var(--color-scale-yellow-2);
border: 1px solid var(--color-scale-yellow-4); border: 1px solid var(--color-scale-yellow-4);
} }
.label-color-3 { .label-color-2 {
background-color: var(--color-scale-orange-9);
color: var(--color-scale-orange-2);
border: 1px solid var(--color-scale-orange-4);
}
.label-color-4 {
background-color: var(--color-scale-red-9);
color: var(--color-scale-red-2);
border: 1px solid var(--color-scale-red-4);
}
.label-color-5 {
background-color: var(--color-scale-purple-9); background-color: var(--color-scale-purple-9);
color: var(--color-scale-purple-2); color: var(--color-scale-purple-2);
border: 1px solid var(--color-scale-purple-4); border: 1px solid var(--color-scale-purple-4);
} }
.label-color-6 { .label-color-3 {
background-color: var(--color-scale-pink-9); background-color: var(--color-scale-pink-9);
color: var(--color-scale-pink-2); color: var(--color-scale-pink-2);
border: 1px solid var(--color-scale-pink-4); border: 1px solid var(--color-scale-pink-4);
} }
.label-color-7 { .label-color-4 {
background-color: var(--color-scale-coral-9); background-color: var(--color-scale-coral-9);
color: var(--color-scale-coral-2); color: var(--color-scale-coral-2);
border: 1px solid var(--color-scale-coral-4); border: 1px solid var(--color-scale-coral-4);
} }
.label-color-5 {
background-color: var(--color-scale-orange-9);
color: var(--color-scale-orange-2);
border: 1px solid var(--color-scale-orange-4);
}
} }
.d-flex { .d-flex {

View file

@ -23,11 +23,12 @@ import { msToString } from '../uiUtils';
import type { TestCase, TestResult, TestStep, TestFile, Stats, TestAttachment, HTMLReport, TestFileSummary, TestCaseSummary } from '@playwright/test/src/reporters/html'; import type { TestCase, TestResult, TestStep, TestFile, Stats, TestAttachment, HTMLReport, TestFileSummary, TestCaseSummary } from '@playwright/test/src/reporters/html';
export const Report: React.FC = () => { export const Report: React.FC = () => {
const searchParams = new URLSearchParams(window.location.hash.slice(1));
const [fetchError, setFetchError] = React.useState<string | undefined>(); const [fetchError, setFetchError] = React.useState<string | undefined>();
const [report, setReport] = React.useState<HTMLReport | undefined>(); const [report, setReport] = React.useState<HTMLReport | undefined>();
const [expandedFiles, setExpandedFiles] = React.useState<Map<string, boolean>>(new Map()); const [expandedFiles, setExpandedFiles] = React.useState<Map<string, boolean>>(new Map());
const [navigationId, setNavigationId] = React.useState<number>(Date.now()); const [filterText, setFilterText] = React.useState(searchParams.get('q') || '');
const [filterText, setFilterText] = React.useState(new URL(window.location.href).searchParams.get('q') || '');
React.useEffect(() => { React.useEffect(() => {
if (report) if (report)
@ -40,8 +41,8 @@ export const Report: React.FC = () => {
setFetchError(e.message); setFetchError(e.message);
} }
window.addEventListener('popstate', () => { window.addEventListener('popstate', () => {
setNavigationId(Date.now()); const params = new URLSearchParams(window.location.hash.slice(1));
setFilterText(new URL(window.location.href).searchParams.get('q') || ''); setFilterText(params.get('q') || '');
}); });
})(); })();
}, [report]); }, [report]);
@ -51,10 +52,10 @@ export const Report: React.FC = () => {
return <div className='vbox columns'> return <div className='vbox columns'>
{!fetchError && <div className='flow-container'> {!fetchError && <div className='flow-container'>
<Route params=''> <Route params=''>
<AllTestFilesSummaryView report={report} filter={filter} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles} navigationId={navigationId}></AllTestFilesSummaryView> <AllTestFilesSummaryView report={report} filter={filter} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles} filterText={filterText} setFilterText={setFilterText}></AllTestFilesSummaryView>
</Route> </Route>
<Route params='q'> <Route params='q'>
<AllTestFilesSummaryView report={report} filter={filter} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles} navigationId={navigationId}></AllTestFilesSummaryView> <AllTestFilesSummaryView report={report} filter={filter} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles} filterText={filterText} setFilterText={setFilterText}></AllTestFilesSummaryView>
</Route> </Route>
<Route params='testId'> <Route params='testId'>
{!!report && <TestCaseView report={report}></TestCaseView>} {!!report && <TestCaseView report={report}></TestCaseView>}
@ -67,29 +68,43 @@ const AllTestFilesSummaryView: React.FC<{
report?: HTMLReport, report?: HTMLReport,
expandedFiles: Map<string, boolean>, expandedFiles: Map<string, boolean>,
setExpandedFiles: (value: Map<string, boolean>) => void, setExpandedFiles: (value: Map<string, boolean>) => void,
navigationId: number, filter: Filter,
filter: Filter filterText: string,
}> = ({ report, filter, expandedFiles, setExpandedFiles, navigationId }) => { setFilterText: (filter: string) => void,
const inputRef = React.useRef<HTMLInputElement | null>(null); }> = ({ report, filter, expandedFiles, setExpandedFiles, filterText, setFilterText }) => {
const filteredFiles = React.useMemo(() => {
const result: { file: TestFileSummary, defaultExpanded: boolean }[] = [];
let visibleTests = 0;
for (const file of report?.files || []) {
const tests = file.tests.filter(t => filter.matches(t));
visibleTests += tests.length;
if (tests.length)
result.push({ file, defaultExpanded: visibleTests < 200 });
}
return result;
}, [report, filter]);
return <div className='file-summary-list'> return <div className='file-summary-list'>
{report && <div className='d-flex'> {report && <div className='d-flex'>
<form className='subnav-search width-full' onSubmit={ <form className='subnav-search width-full' onSubmit={
event => { event => {
event.preventDefault(); event.preventDefault();
navigate(`?q=${inputRef.current?.value || ''}`); navigate(`#?q=${filterText || ''}`);
} }
}> }>
<svg aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16' data-view-component='true' className='octicon subnav-search-icon'> <svg aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16' data-view-component='true' className='octicon subnav-search-icon'>
<path fillRule='evenodd' d='M11.5 7a4.499 4.499 0 11-8.998 0A4.499 4.499 0 0111.5 7zm-.82 4.74a6 6 0 111.06-1.06l3.04 3.04a.75.75 0 11-1.06 1.06l-3.04-3.04z'></path> <path fillRule='evenodd' d='M11.5 7a4.499 4.499 0 11-8.998 0A4.499 4.499 0 0111.5 7zm-.82 4.74a6 6 0 111.06-1.06l3.04 3.04a.75.75 0 11-1.06 1.06l-3.04-3.04z'></path>
</svg> </svg>
{/* Use navigationId to reset defaultValue */} {/* Use navigationId to reset defaultValue */}
<input key={`filter-${navigationId}`} type='search' className='form-control subnav-search-input input-contrast width-full' defaultValue={filter.expression} ref={inputRef}></input> <input type='search' spellCheck={false} className='form-control subnav-search-input input-contrast width-full' value={filterText} onChange={e => {
setFilterText(e.target.value);
}}></input>
</form> </form>
<div className='ml-2 pl-2 d-flex'> <div className='ml-2 pl-2 d-flex'>
<StatsNavView stats={report.stats}></StatsNavView> <StatsNavView stats={report.stats}></StatsNavView>
</div> </div>
</div>} </div>}
{report && (report.files || []).filter(f => !!f.tests.find(t => filter.matches(t))).map((file, i) => { {report && filteredFiles.map(({ file, defaultExpanded }) => {
return <TestFileSummaryView return <TestFileSummaryView
key={`file-${file.fileId}`} key={`file-${file.fileId}`}
report={report} report={report}
@ -97,7 +112,7 @@ const AllTestFilesSummaryView: React.FC<{
isFileExpanded={fileId => { isFileExpanded={fileId => {
const value = expandedFiles.get(fileId); const value = expandedFiles.get(fileId);
if (value === undefined) if (value === undefined)
return i === 0; return defaultExpanded;
return !!value; return !!value;
}} }}
setFileExpanded={(fileId, expanded) => { setFileExpanded={(fileId, expanded) => {
@ -124,13 +139,12 @@ const TestFileSummaryView: React.FC<{
header={<span> header={<span>
<span style={{ float: 'right' }}>{msToString(file.stats.duration)}</span> <span style={{ float: 'right' }}>{msToString(file.stats.duration)}</span>
{file.fileName} {file.fileName}
<StatsInlineView stats={file.stats}></StatsInlineView>
</span>}> </span>}>
{file.tests.filter(t => filter.matches(t)).map(test => {file.tests.filter(t => filter.matches(t)).map(test =>
<div key={`test-${test.testId}`} className={'test-summary outcome-' + test.outcome}> <div key={`test-${test.testId}`} className={'test-summary outcome-' + test.outcome}>
<span style={{ float: 'right' }}>{msToString(test.duration)}</span> <span style={{ float: 'right' }}>{msToString(test.duration)}</span>
{statusIcon(test.outcome)} {statusIcon(test.outcome)}
<Link href={`?testId=${test.testId}`}> <Link href={`#?testId=${test.testId}`}>
{test.title} {test.title}
<span className='test-summary-path'> {test.path.join(' ')}</span> <span className='test-summary-path'> {test.path.join(' ')}</span>
</Link> </Link>
@ -144,10 +158,11 @@ const TestFileSummaryView: React.FC<{
const TestCaseView: React.FC<{ const TestCaseView: React.FC<{
report: HTMLReport, report: HTMLReport,
}> = ({ report }) => { }> = ({ report }) => {
const searchParams = new URLSearchParams(window.location.hash.slice(1));
const [test, setTest] = React.useState<TestCase | undefined>(); const [test, setTest] = React.useState<TestCase | undefined>();
const testId = searchParams.get('testId');
React.useEffect(() => { React.useEffect(() => {
(async () => { (async () => {
const testId = new URL(window.location.href).searchParams.get('testId');
if (!testId || testId === test?.testId) if (!testId || testId === test?.testId)
return; return;
const fileId = testId.split('-')[0]; const fileId = testId.split('-')[0];
@ -162,7 +177,7 @@ const TestCaseView: React.FC<{
} }
} }
})(); })();
}, [test, report]); }, [test, report, testId]);
const [selectedResultIndex, setSelectedResultIndex] = React.useState(0); const [selectedResultIndex, setSelectedResultIndex] = React.useState(0);
return <div className='test-case-column vbox'> return <div className='test-case-column vbox'>
@ -266,33 +281,23 @@ const StepTreeItem: React.FC<{
} : undefined} depth={depth}></TreeItem>; } : undefined} depth={depth}></TreeItem>;
}; };
const StatsInlineView: React.FC<{
stats: Stats
}> = ({ stats }) => {
return <span className='stats-line'>
{!!stats.expected && <span className='stats'>Passed <span className='counter' style={{ backgroundColor: 'var(--color-scale-green-1)' }}>{stats.expected}</span></span>}
{!!stats.unexpected && <span className='stats'>Failed <span className='counter' style={{ backgroundColor: 'var(--color-scale-red-1)' }}>{stats.unexpected}</span></span>}
{!!stats.flaky && <span className='stats'>Flaky <span className='counter' style={{ backgroundColor: 'var(--color-scale-yellow-1)' }}>{stats.flaky}</span></span>}
</span>;
};
const StatsNavView: React.FC<{ const StatsNavView: React.FC<{
stats: Stats stats: Stats
}> = ({ stats }) => { }> = ({ stats }) => {
return <nav className='subnav-links d-flex no-wrap'> return <nav className='subnav-links d-flex no-wrap'>
<Link className='subnav-item' href='?'> <Link className='subnav-item' href='#?'>
All <span className='d-inline counter'>{stats.total}</span> All <span className='d-inline counter'>{stats.total}</span>
</Link> </Link>
<Link className='subnav-item' href='?q=outcome:expected'> <Link className='subnav-item' href='#?q=s:passed'>
Passed <span className='d-inline counter' style={{ backgroundColor: 'var(--color-scale-green-1)' }}>{stats.expected}</span> Passed <span className='d-inline counter'>{stats.expected}</span>
</Link> </Link>
<Link className='subnav-item' href='?q=outcome:unexpected'> <Link className='subnav-item' href='#?q=s:failed'>
Failed <span className='d-inline counter' style={{ backgroundColor: 'var(--color-scale-red-1)' }}>{stats.unexpected}</span> {!!stats.unexpected && statusIcon('unexpected')} Failed <span className='d-inline counter'>{stats.unexpected}</span>
</Link> </Link>
<Link className='subnav-item' href='?q=outcome:flaky'> <Link className='subnav-item' href='#?q=s:flaky'>
Flaky <span className='d-inline counter' style={{ backgroundColor: 'var(--color-scale-yellow-1)' }}>{stats.flaky}</span> {!!stats.flaky && statusIcon('flaky')} Flaky <span className='d-inline counter'>{stats.flaky}</span>
</Link> </Link>
<Link className='subnav-item' href='?q=outcome:skipped'> <Link className='subnav-item' href='#?q=s:skipped'>
Skipped <span className='d-inline counter'>{stats.skipped}</span> Skipped <span className='d-inline counter'>{stats.skipped}</span>
</Link> </Link>
</nav>; </nav>;
@ -436,8 +441,8 @@ const ProjectLink: React.FunctionComponent<{
report: HTMLReport, report: HTMLReport,
projectName: string, projectName: string,
}> = ({ report, projectName }) => { }> = ({ report, projectName }) => {
return <Link href={`?q=project:${projectName}`}> return <Link href={`#?q=p:${projectName}`}>
<span className={'label label-color-' + (report.projectNames.indexOf(projectName) % 8)}> <span className={'label label-color-' + (report.projectNames.indexOf(projectName) % 6)}>
{projectName} {projectName}
</span> </span>
</Link>; </Link>;
@ -448,22 +453,18 @@ const Link: React.FunctionComponent<{
className?: string, className?: string,
children: any, children: any,
}> = ({ href, className, children }) => { }> = ({ href, className, children }) => {
return <a className={`no-decorations${className ? ' ' + className : ''}`} onClick={event => { return <a className={`no-decorations${className ? ' ' + className : ''}`} href={href}>{children}</a>;
event.preventDefault();
event.stopPropagation();
navigate(href);
}} href={href}>{children}</a>;
}; };
const Route: React.FunctionComponent<{ const Route: React.FunctionComponent<{
params: string, params: string,
children: any children: any
}> = ({ params, children }) => { }> = ({ params, children }) => {
const initialParams = [...new URL(window.location.href).searchParams.keys()].join('&'); const initialParams = [...new URLSearchParams(window.location.hash.slice(1)).keys()].join('&');
const [currentParams, setCurrentParam] = React.useState(initialParams); const [currentParams, setCurrentParam] = React.useState(initialParams);
React.useEffect(() => { React.useEffect(() => {
const listener = () => { const listener = () => {
const newParams = [...new URL(window.location.href).searchParams.keys()].join('&'); const newParams = [...new URLSearchParams(window.location.hash.slice(1)).keys()].join('&');
setCurrentParam(newParams); setCurrentParam(newParams);
}; };
window.addEventListener('popstate', listener); window.addEventListener('popstate', listener);
@ -476,28 +477,33 @@ class Filter {
project = new Set<string>(); project = new Set<string>();
outcome = new Set<string>(); outcome = new Set<string>();
text: string[] = []; text: string[] = [];
expression: string;
private static regex = /(".*?"|[\w]+:|[^"\s:]+)(?=\s*|\s*$)/g; private static regex = /(".*?"|[\w]+:|[^"\s:]+)(?=\s*|\s*$)/g;
private constructor(expression: string) { empty(): boolean {
this.expression = expression; return this.project.size + this.outcome.size + this.text.length === 0;
} }
static parse(expression: string): Filter { static parse(expression: string): Filter {
const filter = new Filter(expression); const filter = new Filter();
const match = (expression.match(Filter.regex) || []).map(t => { const match = (expression.match(Filter.regex) || []).map(t => {
return t.startsWith('"') && t.endsWith('"') ? t.substring(1, t.length - 1) : t; return t.startsWith('"') && t.endsWith('"') ? t.substring(1, t.length - 1) : t;
}); });
for (let i = 0; i < match.length; ++i) { for (let i = 0; i < match.length; ++i) {
if (match[i] === 'project:' && match[i + 1]) { if (match[i] === 'p:' && match[i + 1]) {
filter.project.add(match[++i]); filter.project.add(match[++i]);
continue; continue;
} }
if (match[i] === 'outcome:' && match[i + 1]) { if (match[i] === 's:' && match[i + 1]) {
filter.outcome.add(match[++i]); const status = match[++i];
if (status === 'passed')
filter.outcome.add('expected');
else if (status === 'failed')
filter.outcome.add('unexpected');
else
filter.outcome.add(status);
continue; continue;
} }
filter.text.push(match[i]); filter.text.push(match[i].toLowerCase());
} }
return filter; return filter;
} }
@ -508,7 +514,9 @@ class Filter {
if (this.outcome.size && !this.outcome.has(test.outcome)) if (this.outcome.size && !this.outcome.has(test.outcome))
return false; return false;
if (this.text.length) { if (this.text.length) {
const fullTitle = test.path.join(' ') + test.title; if (!(test as any).fullTitle)
(test as any).fullTitle = (test.path.join(' ') + test.title).toLowerCase();
const fullTitle = (test as any).fullTitle;
const matches = !!this.text.find(t => fullTitle.includes(t)); const matches = !!this.text.find(t => fullTitle.includes(t));
if (!matches) if (!matches)
return false; return false;

View file

@ -105,10 +105,12 @@ class HtmlReporter {
private config!: FullConfig; private config!: FullConfig;
private suite!: Suite; private suite!: Suite;
private _outputFolder: string | undefined; private _outputFolder: string | undefined;
private _open: 'always' | 'never' | 'on-failure';
constructor(options: { outputFolder?: string } = {}) { constructor(options: { outputFolder?: string, open?: 'always' | 'never' | 'on-failure' } = {}) {
// TODO: resolve relative to config. // TODO: resolve relative to config.
this._outputFolder = options.outputFolder; this._outputFolder = options.outputFolder;
this._open = options.open || 'on-failure';
} }
onBegin(config: FullConfig, suite: Suite) { onBegin(config: FullConfig, suite: Suite) {
@ -128,16 +130,18 @@ class HtmlReporter {
const builder = new HtmlBuilder(reportFolder, this.config.rootDir); const builder = new HtmlBuilder(reportFolder, this.config.rootDir);
const ok = builder.build(reports); const ok = builder.build(reports);
if (!process.env.PWTEST_SKIP_TEST_OUTPUT) { if (process.env.PWTEST_SKIP_TEST_OUTPUT || process.env.CI)
if (!ok && !process.env.CI && !process.env.PWTEST_SKIP_TEST_OUTPUT) { return;
await showHTMLReport(reportFolder);
} else { const shouldOpen = this._open === 'always' || (!ok && this._open === 'on-failure');
console.log(''); if (shouldOpen) {
console.log('All tests passed. To open last HTML report run:'); await showHTMLReport(reportFolder);
console.log(colors.cyan(` } else {
console.log('');
console.log('To open last HTML report run:');
console.log(colors.cyan(`
npx playwright show-report npx playwright show-report
`)); `));
}
} }
} }
} }

View file

@ -48,9 +48,11 @@ const config: Config<CoverageWorkerOptions & PlaywrightWorkerOptions & Playwrigh
preserveOutput: process.env.CI ? 'failures-only' : 'always', preserveOutput: process.env.CI ? 'failures-only' : 'always',
retries: process.env.CI ? 3 : 0, retries: process.env.CI ? 3 : 0,
reporter: process.env.CI ? [ reporter: process.env.CI ? [
[ 'dot' ], ['dot'],
[ 'json', { outputFile: path.join(outputDir, 'report.json') } ], ['json', { outputFile: path.join(outputDir, 'report.json') }],
] : 'html', ] : [
['html', { open: 'on-failure' }]
],
projects: [], projects: [],
webServer: mode === 'service' ? { webServer: mode === 'service' ? {
command: 'npx playwright experimental-grid-server', command: 'npx playwright experimental-grid-server',