2021-06-07 02:09:53 +02: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 { codeFrameColumns } from '@babel/code-frame';
|
|
|
|
|
|
import colors from 'colors/safe';
|
|
|
|
|
|
import fs from 'fs';
|
|
|
|
|
|
// @ts-ignore
|
|
|
|
|
|
import milliseconds from 'ms';
|
|
|
|
|
|
import path from 'path';
|
|
|
|
|
|
import StackUtils from 'stack-utils';
|
2021-08-18 01:41:36 +02:00
|
|
|
|
import { FullConfig, TestCase, Suite, TestResult, TestError, Reporter, FullResult, TestStep } from '../../../types/testReporter';
|
2021-06-07 02:09:53 +02:00
|
|
|
|
|
|
|
|
|
|
const stackUtils = new StackUtils();
|
|
|
|
|
|
|
2021-08-12 01:44:19 +02:00
|
|
|
|
type TestResultOutput = { chunk: string | Buffer, type: 'stdout' | 'stderr' };
|
2021-09-30 23:18:36 +02:00
|
|
|
|
export type PositionInFile = { column: number; line: number };
|
2021-08-12 01:44:19 +02:00
|
|
|
|
const kOutputSymbol = Symbol('output');
|
|
|
|
|
|
|
2021-06-07 02:09:53 +02:00
|
|
|
|
export class BaseReporter implements Reporter {
|
|
|
|
|
|
duration = 0;
|
|
|
|
|
|
config!: FullConfig;
|
|
|
|
|
|
suite!: Suite;
|
2021-06-29 19:55:46 +02:00
|
|
|
|
result!: FullResult;
|
2021-06-07 02:09:53 +02:00
|
|
|
|
fileDurations = new Map<string, number>();
|
|
|
|
|
|
monotonicStartTime: number = 0;
|
2021-08-12 01:44:19 +02:00
|
|
|
|
private printTestOutput = !process.env.PWTEST_SKIP_TEST_OUTPUT;
|
2021-06-07 02:09:53 +02:00
|
|
|
|
|
|
|
|
|
|
onBegin(config: FullConfig, suite: Suite) {
|
|
|
|
|
|
this.monotonicStartTime = monotonicTime();
|
|
|
|
|
|
this.config = config;
|
|
|
|
|
|
this.suite = suite;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2021-08-12 01:44:19 +02:00
|
|
|
|
onStdOut(chunk: string | Buffer, test?: TestCase, result?: TestResult) {
|
|
|
|
|
|
this._appendOutput({ chunk, type: 'stdout' }, result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onStdErr(chunk: string | Buffer, test?: TestCase, result?: TestResult) {
|
|
|
|
|
|
this._appendOutput({ chunk, type: 'stderr' }, result);
|
2021-06-07 02:09:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2021-08-12 01:44:19 +02:00
|
|
|
|
private _appendOutput(output: TestResultOutput, result: TestResult | undefined) {
|
|
|
|
|
|
if (!result)
|
|
|
|
|
|
return;
|
|
|
|
|
|
(result as any)[kOutputSymbol] = (result as any)[kOutputSymbol] || [];
|
|
|
|
|
|
(result as any)[kOutputSymbol].push(output);
|
2021-06-07 02:09:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2021-07-19 23:54:18 +02:00
|
|
|
|
onTestEnd(test: TestCase, result: TestResult) {
|
2021-07-17 00:23:50 +02:00
|
|
|
|
const projectName = test.titlePath()[1];
|
2021-07-16 07:02:10 +02:00
|
|
|
|
const relativePath = relativeTestPath(this.config, test);
|
2021-07-17 00:23:50 +02:00
|
|
|
|
const fileAndProject = (projectName ? `[${projectName}] › ` : '') + relativePath;
|
2021-06-15 07:45:58 +02:00
|
|
|
|
const duration = this.fileDurations.get(fileAndProject) || 0;
|
|
|
|
|
|
this.fileDurations.set(fileAndProject, duration + result.duration);
|
2021-06-07 02:09:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onError(error: TestError) {
|
|
|
|
|
|
console.log(formatError(error));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2021-06-29 19:55:46 +02:00
|
|
|
|
async onEnd(result: FullResult) {
|
2021-06-07 02:09:53 +02:00
|
|
|
|
this.duration = monotonicTime() - this.monotonicStartTime;
|
2021-06-29 19:55:46 +02:00
|
|
|
|
this.result = result;
|
2021-06-07 02:09:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private _printSlowTests() {
|
2021-06-15 07:45:58 +02:00
|
|
|
|
if (!this.config.reportSlowTests)
|
|
|
|
|
|
return;
|
2021-06-07 02:09:53 +02:00
|
|
|
|
const fileDurations = [...this.fileDurations.entries()];
|
|
|
|
|
|
fileDurations.sort((a, b) => b[1] - a[1]);
|
2021-06-15 07:45:58 +02:00
|
|
|
|
const count = Math.min(fileDurations.length, this.config.reportSlowTests.max || Number.POSITIVE_INFINITY);
|
|
|
|
|
|
for (let i = 0; i < count; ++i) {
|
2021-06-07 02:09:53 +02:00
|
|
|
|
const duration = fileDurations[i][1];
|
2021-06-15 07:45:58 +02:00
|
|
|
|
if (duration <= this.config.reportSlowTests.threshold)
|
2021-06-07 02:09:53 +02:00
|
|
|
|
break;
|
2021-06-15 07:45:58 +02:00
|
|
|
|
console.log(colors.yellow(' Slow test: ') + fileDurations[i][0] + colors.yellow(` (${milliseconds(duration)})`));
|
2021-06-07 02:09:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
epilogue(full: boolean) {
|
|
|
|
|
|
let skipped = 0;
|
|
|
|
|
|
let expected = 0;
|
2021-07-29 00:43:37 +02:00
|
|
|
|
const skippedWithError: TestCase[] = [];
|
2021-07-19 23:54:18 +02:00
|
|
|
|
const unexpected: TestCase[] = [];
|
|
|
|
|
|
const flaky: TestCase[] = [];
|
2021-06-07 02:09:53 +02:00
|
|
|
|
|
2021-07-16 21:40:33 +02:00
|
|
|
|
this.suite.allTests().forEach(test => {
|
2021-07-19 02:40:59 +02:00
|
|
|
|
switch (test.outcome()) {
|
2021-07-29 00:43:37 +02:00
|
|
|
|
case 'skipped': {
|
|
|
|
|
|
++skipped;
|
|
|
|
|
|
if (test.results.some(result => !!result.error))
|
|
|
|
|
|
skippedWithError.push(test);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2021-06-07 02:09:53 +02:00
|
|
|
|
case 'expected': ++expected; break;
|
|
|
|
|
|
case 'unexpected': unexpected.push(test); break;
|
|
|
|
|
|
case 'flaky': flaky.push(test); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2021-08-20 03:20:53 +02:00
|
|
|
|
const failuresToPrint = [...unexpected, ...flaky, ...skippedWithError];
|
|
|
|
|
|
if (full && failuresToPrint.length) {
|
2021-06-07 02:09:53 +02:00
|
|
|
|
console.log('');
|
2021-08-20 03:20:53 +02:00
|
|
|
|
this._printFailures(failuresToPrint);
|
2021-06-07 02:09:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this._printSlowTests();
|
|
|
|
|
|
|
|
|
|
|
|
console.log('');
|
|
|
|
|
|
if (unexpected.length) {
|
|
|
|
|
|
console.log(colors.red(` ${unexpected.length} failed`));
|
2021-09-16 01:28:57 +02:00
|
|
|
|
for (const test of unexpected)
|
|
|
|
|
|
console.log(colors.red(formatTestHeader(this.config, test, ' ')));
|
2021-06-07 02:09:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
if (flaky.length) {
|
2021-09-16 01:28:57 +02:00
|
|
|
|
console.log(colors.yellow(` ${flaky.length} flaky`));
|
|
|
|
|
|
for (const test of flaky)
|
|
|
|
|
|
console.log(colors.yellow(formatTestHeader(this.config, test, ' ')));
|
2021-06-07 02:09:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
if (skipped)
|
|
|
|
|
|
console.log(colors.yellow(` ${skipped} skipped`));
|
|
|
|
|
|
if (expected)
|
|
|
|
|
|
console.log(colors.green(` ${expected} passed`) + colors.dim(` (${milliseconds(this.duration)})`));
|
2021-06-29 19:55:46 +02:00
|
|
|
|
if (this.result.status === 'timedout')
|
|
|
|
|
|
console.log(colors.red(` Timed out waiting ${this.config.globalTimeout / 1000}s for the entire test run`));
|
2021-06-07 02:09:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2021-07-19 23:54:18 +02:00
|
|
|
|
private _printFailures(failures: TestCase[]) {
|
2021-06-07 02:09:53 +02:00
|
|
|
|
failures.forEach((test, index) => {
|
2021-08-12 01:44:19 +02:00
|
|
|
|
console.log(formatFailure(this.config, test, index + 1, this.printTestOutput));
|
2021-06-07 02:09:53 +02:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2021-08-25 21:19:50 +02:00
|
|
|
|
willRetry(test: TestCase): boolean {
|
|
|
|
|
|
return test.outcome() === 'unexpected' && test.results.length <= test.retries;
|
2021-06-07 02:09:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2021-08-12 01:44:19 +02:00
|
|
|
|
export function formatFailure(config: FullConfig, test: TestCase, index?: number, stdio?: boolean): string {
|
2021-09-14 03:07:40 +02:00
|
|
|
|
const lines: string[] = [];
|
2021-09-16 01:28:57 +02:00
|
|
|
|
lines.push(colors.red(formatTestHeader(config, test, ' ', index)));
|
2021-06-07 02:09:53 +02:00
|
|
|
|
for (const result of test.results) {
|
2021-08-05 22:36:47 +02:00
|
|
|
|
const resultTokens = formatResultFailure(test, result, ' ');
|
2021-07-29 00:43:37 +02:00
|
|
|
|
if (!resultTokens.length)
|
2021-06-07 02:09:53 +02:00
|
|
|
|
continue;
|
2021-07-29 00:43:37 +02:00
|
|
|
|
if (result.retry) {
|
2021-09-14 03:07:40 +02:00
|
|
|
|
lines.push('');
|
|
|
|
|
|
lines.push(colors.gray(pad(` Retry #${result.retry}`, '-')));
|
|
|
|
|
|
}
|
|
|
|
|
|
lines.push(...resultTokens);
|
|
|
|
|
|
for (let i = 0; i < result.attachments.length; ++i) {
|
|
|
|
|
|
const attachment = result.attachments[i];
|
|
|
|
|
|
lines.push('');
|
|
|
|
|
|
lines.push(colors.cyan(pad(` attachment #${i + 1}: ${attachment.name} (${attachment.contentType})`, '-')));
|
|
|
|
|
|
if (attachment.path) {
|
|
|
|
|
|
const relativePath = path.relative(process.cwd(), attachment.path);
|
|
|
|
|
|
lines.push(colors.cyan(` ${relativePath}`));
|
|
|
|
|
|
// Make this extensible
|
|
|
|
|
|
if (attachment.name === 'trace') {
|
|
|
|
|
|
lines.push(colors.cyan(` Usage:`));
|
|
|
|
|
|
lines.push('');
|
|
|
|
|
|
lines.push(colors.cyan(` npx playwright show-trace ${relativePath}`));
|
|
|
|
|
|
lines.push('');
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (attachment.contentType.startsWith('text/')) {
|
|
|
|
|
|
let text = attachment.body!.toString();
|
|
|
|
|
|
if (text.length > 300)
|
|
|
|
|
|
text = text.slice(0, 300) + '...';
|
|
|
|
|
|
lines.push(colors.cyan(` ${text}`));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
lines.push(colors.cyan(pad(' ', '-')));
|
2021-07-29 00:43:37 +02:00
|
|
|
|
}
|
2021-08-12 01:44:19 +02:00
|
|
|
|
const output = ((result as any)[kOutputSymbol] || []) as TestResultOutput[];
|
|
|
|
|
|
if (stdio && output.length) {
|
|
|
|
|
|
const outputText = output.map(({ chunk, type }) => {
|
|
|
|
|
|
const text = chunk.toString('utf8');
|
|
|
|
|
|
if (type === 'stderr')
|
|
|
|
|
|
return colors.red(stripAnsiEscapes(text));
|
|
|
|
|
|
return text;
|
|
|
|
|
|
}).join('');
|
2021-09-14 03:07:40 +02:00
|
|
|
|
lines.push('');
|
|
|
|
|
|
lines.push(colors.gray(pad('--- Test output', '-')) + '\n\n' + outputText + '\n' + pad('', '-'));
|
2021-08-12 01:44:19 +02:00
|
|
|
|
}
|
2021-06-07 02:09:53 +02:00
|
|
|
|
}
|
2021-09-14 03:07:40 +02:00
|
|
|
|
lines.push('');
|
|
|
|
|
|
return lines.join('\n');
|
2021-06-07 02:09:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2021-08-05 22:36:47 +02:00
|
|
|
|
export function formatResultFailure(test: TestCase, result: TestResult, initialIndent: string): string[] {
|
|
|
|
|
|
const resultTokens: string[] = [];
|
|
|
|
|
|
if (result.status === 'timedOut') {
|
|
|
|
|
|
resultTokens.push('');
|
|
|
|
|
|
resultTokens.push(indent(colors.red(`Timeout of ${test.timeout}ms exceeded.`), initialIndent));
|
|
|
|
|
|
}
|
2021-08-25 21:19:50 +02:00
|
|
|
|
if (result.status === 'passed' && test.expectedStatus === 'failed') {
|
|
|
|
|
|
resultTokens.push('');
|
|
|
|
|
|
resultTokens.push(indent(colors.red(`Expected to fail, but passed.`), initialIndent));
|
|
|
|
|
|
}
|
2021-08-05 22:36:47 +02:00
|
|
|
|
if (result.error !== undefined)
|
|
|
|
|
|
resultTokens.push(indent(formatError(result.error, test.location.file), initialIndent));
|
|
|
|
|
|
return resultTokens;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2021-07-19 23:54:18 +02:00
|
|
|
|
function relativeTestPath(config: FullConfig, test: TestCase): string {
|
2021-07-16 21:40:33 +02:00
|
|
|
|
return path.relative(config.rootDir, test.location.file) || path.basename(test.location.file);
|
2021-06-15 07:45:58 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2021-08-18 01:41:36 +02:00
|
|
|
|
function stepSuffix(step: TestStep | undefined) {
|
|
|
|
|
|
const stepTitles = step ? step.titlePath() : [];
|
|
|
|
|
|
return stepTitles.map(t => ' › ' + t).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function formatTestTitle(config: FullConfig, test: TestCase, step?: TestStep): string {
|
2021-07-17 00:23:50 +02:00
|
|
|
|
// root, project, file, ...describes, test
|
|
|
|
|
|
const [, projectName, , ...titles] = test.titlePath();
|
|
|
|
|
|
const location = `${relativeTestPath(config, test)}:${test.location.line}:${test.location.column}`;
|
|
|
|
|
|
const projectTitle = projectName ? `[${projectName}] › ` : '';
|
2021-08-18 01:41:36 +02:00
|
|
|
|
return `${projectTitle}${location} › ${titles.join(' ')}${stepSuffix(step)}`;
|
2021-06-07 02:09:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2021-07-19 23:54:18 +02:00
|
|
|
|
function formatTestHeader(config: FullConfig, test: TestCase, indent: string, index?: number): string {
|
2021-06-07 02:09:53 +02:00
|
|
|
|
const title = formatTestTitle(config, test);
|
2021-08-25 21:19:50 +02:00
|
|
|
|
const header = `${indent}${index ? index + ') ' : ''}${title}`;
|
2021-09-16 01:28:57 +02:00
|
|
|
|
return pad(header, '=');
|
2021-06-07 02:09:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2021-09-01 01:34:52 +02:00
|
|
|
|
export function formatError(error: TestError, file?: string) {
|
2021-06-07 02:09:53 +02:00
|
|
|
|
const stack = error.stack;
|
2021-09-30 23:18:36 +02:00
|
|
|
|
const tokens = [''];
|
2021-06-07 02:09:53 +02:00
|
|
|
|
if (stack) {
|
2021-09-30 23:18:36 +02:00
|
|
|
|
const { message, stackLines, position } = prepareErrorStack(
|
|
|
|
|
|
stack,
|
|
|
|
|
|
file
|
|
|
|
|
|
);
|
|
|
|
|
|
tokens.push(message);
|
|
|
|
|
|
|
|
|
|
|
|
const codeFrame = generateCodeFrame(file, position);
|
|
|
|
|
|
if (codeFrame) {
|
2021-06-07 02:09:53 +02:00
|
|
|
|
tokens.push('');
|
2021-09-30 23:18:36 +02:00
|
|
|
|
tokens.push(codeFrame);
|
2021-06-07 02:09:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
tokens.push('');
|
2021-09-16 06:28:36 +02:00
|
|
|
|
tokens.push(colors.dim(stackLines.join('\n')));
|
2021-08-28 16:19:45 +02:00
|
|
|
|
} else if (error.message) {
|
|
|
|
|
|
tokens.push(error.message);
|
2021-09-30 23:18:36 +02:00
|
|
|
|
} else if (error.value) {
|
2021-06-07 02:09:53 +02:00
|
|
|
|
tokens.push(error.value);
|
|
|
|
|
|
}
|
|
|
|
|
|
return tokens.join('\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function pad(line: string, char: string): string {
|
2021-08-12 01:44:19 +02:00
|
|
|
|
if (line)
|
|
|
|
|
|
line += ' ';
|
|
|
|
|
|
return line + colors.gray(char.repeat(Math.max(0, 100 - line.length)));
|
2021-06-07 02:09:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function indent(lines: string, tab: string) {
|
|
|
|
|
|
return lines.replace(/^(?=.+$)/gm, tab);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2021-09-30 23:18:36 +02:00
|
|
|
|
function generateCodeFrame(file?: string, position?: PositionInFile): string | undefined {
|
|
|
|
|
|
if (!position || !file)
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
|
|
const source = fs.readFileSync(file!, 'utf8');
|
|
|
|
|
|
const codeFrame = codeFrameColumns(
|
|
|
|
|
|
source,
|
|
|
|
|
|
{ start: position },
|
|
|
|
|
|
{ highlightCode: colors.enabled }
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return codeFrame;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function prepareErrorStack(stack: string, file?: string): {
|
|
|
|
|
|
message: string;
|
|
|
|
|
|
stackLines: string[];
|
|
|
|
|
|
position?: PositionInFile;
|
|
|
|
|
|
} {
|
|
|
|
|
|
const lines = stack.split('\n');
|
|
|
|
|
|
let firstStackLine = lines.findIndex(line => line.startsWith(' at '));
|
|
|
|
|
|
if (firstStackLine === -1) firstStackLine = lines.length;
|
|
|
|
|
|
const message = lines.slice(0, firstStackLine).join('\n');
|
|
|
|
|
|
const stackLines = lines.slice(firstStackLine);
|
|
|
|
|
|
const position = file ? positionInFile(stackLines, file) : undefined;
|
|
|
|
|
|
return {
|
|
|
|
|
|
message,
|
|
|
|
|
|
stackLines,
|
|
|
|
|
|
position,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2021-09-16 06:28:36 +02:00
|
|
|
|
function positionInFile(stackLines: string[], file: string): { column: number; line: number; } | undefined {
|
2021-06-07 02:09:53 +02:00
|
|
|
|
// Stack will have /private/var/folders instead of /var/folders on Mac.
|
|
|
|
|
|
file = fs.realpathSync(file);
|
2021-09-16 06:28:36 +02:00
|
|
|
|
for (const line of stackLines) {
|
2021-06-07 02:09:53 +02:00
|
|
|
|
const parsed = stackUtils.parseLine(line);
|
|
|
|
|
|
if (!parsed || !parsed.file)
|
|
|
|
|
|
continue;
|
|
|
|
|
|
if (path.resolve(process.cwd(), parsed.file) === file)
|
2021-09-27 18:58:08 +02:00
|
|
|
|
return { column: parsed.column || 0, line: parsed.line || 0 };
|
2021-06-07 02:09:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function monotonicTime(): number {
|
|
|
|
|
|
const [seconds, nanoseconds] = process.hrtime();
|
|
|
|
|
|
return seconds * 1000 + (nanoseconds / 1000000 | 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const asciiRegex = new RegExp('[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))', 'g');
|
2021-08-12 01:44:19 +02:00
|
|
|
|
export function stripAnsiEscapes(str: string): string {
|
2021-06-07 02:09:53 +02:00
|
|
|
|
return str.replace(asciiRegex, '');
|
|
|
|
|
|
}
|