feat(test runner): allow overriding TTY params

- Move all tty-related craft into `tty.ts`.
- Inherit `isTTY` from the runner process into worker process,
  instead of always pretending `isTTY === true` in the worker.
- Respect `env.PLAYWRIGHT_TTY` that overrides tty. Supported:
  - `0` and `false` to disable tty;
  - `80` to set columns;
  - `80x25` to set columns and rows;
  - `80x25x8` to set columns, rows and color depth.
This commit is contained in:
Dmitry Gozman 2024-03-23 19:04:09 -07:00
parent 1539cde034
commit fc915b68c6
10 changed files with 192 additions and 123 deletions

View file

@ -18,6 +18,7 @@ import util from 'util';
import { type SerializedCompilationCache, serializeCompilationCache } from '../transform/compilationCache';
import type { ConfigLocation, FullConfigInternal } from './config';
import type { ReporterDescription, TestInfoError, TestStatus } from '../../types/test';
import type { TTYParams } from './tty';
export type ConfigCLIOverrides = {
forbidOnly?: boolean;
@ -46,15 +47,9 @@ export type SerializedConfig = {
compilationCache?: SerializedCompilationCache;
};
export type TtyParams = {
rows: number | undefined;
columns: number | undefined;
colorDepth: number;
};
export type ProcessInitParams = {
stdoutParams: TtyParams;
stderrParams: TtyParams;
stdoutTTY: TTYParams | undefined;
stderrTTY: TTYParams | undefined;
processName: string;
};

View file

@ -14,13 +14,13 @@
* limitations under the License.
*/
import type { WriteStream } from 'tty';
import type { EnvProducedPayload, ProcessInitParams, TtyParams } from './ipc';
import type { EnvProducedPayload, ProcessInitParams } from './ipc';
import { startProfiling, stopProfiling } from 'playwright-core/lib/utils';
import type { TestInfoError } from '../../types/test';
import { serializeError } from '../util';
import { registerESMLoader } from './esmLoaderHost';
import { execArgvWithoutExperimentalLoaderOptions } from '../transform/esmUtils';
import { setTTYParams } from './tty';
export type ProtocolRequest = {
id: number;
@ -67,8 +67,8 @@ const startingEnv = { ...process.env };
process.on('message', async (message: any) => {
if (message.method === '__init__') {
const { processParams, runnerParams, runnerScript } = message.params as { processParams: ProcessInitParams, runnerParams: any, runnerScript: string };
setTtyParams(process.stdout, processParams.stdoutParams);
setTtyParams(process.stderr, processParams.stderrParams);
setTTYParams(process.stdout, processParams.stdoutTTY);
setTTYParams(process.stderr, processParams.stderrTTY);
void startProfiling();
const { create } = require(runnerScript);
processRunner = create(runnerParams) as ProcessRunner;
@ -117,40 +117,3 @@ function sendMessageToParent(message: { method: string, params?: any }) {
// Can throw when closing.
}
}
function setTtyParams(stream: WriteStream, params: TtyParams) {
stream.isTTY = true;
if (params.rows)
stream.rows = params.rows;
if (params.columns)
stream.columns = params.columns;
stream.getColorDepth = () => params.colorDepth;
stream.hasColors = ((count = 16) => {
// count is optional and the first argument may actually be env.
if (typeof count !== 'number')
count = 16;
return count <= 2 ** params.colorDepth;
})as any;
// Stubs for the rest of the methods to avoid exceptions in user code.
stream.clearLine = (dir: any, callback?: () => void) => {
callback?.();
return true;
};
stream.clearScreenDown = (callback?: () => void) => {
callback?.();
return true;
};
(stream as any).cursorTo = (x: number, y?: number | (() => void), callback?: () => void) => {
if (callback)
callback();
else if (y instanceof Function)
y();
return true;
};
stream.moveCursor = (dx: number, dy: number, callback?: () => void) => {
callback?.();
return true;
};
stream.getWindowSize = () => [stream.columns, stream.rows];
}

View file

@ -0,0 +1,121 @@
/**
* Copyright Microsoft Corporation. All rights reserved.
*
* 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 type { WriteStream } from 'tty';
import { colors as realColors } from 'playwright-core/lib/utilsBundle';
export type TTYParams = {
rows: number;
columns: number;
colorDepth: number;
};
function getTTYParams(stream: WriteStream): TTYParams | undefined {
// Explicitly disabled.
if (process.env.PLAYWRIGHT_TTY === 'false' || process.env.PLAYWRIGHT_TTY === '0')
return;
// Format is COLUMNS[xROWS[xDEPTH]] similar to xvfb screen.
const match = (process.env.PLAYWRIGHT_TTY || '').match(/^(\d+)(?:x(\d+))?(?:x(\d+))?$/);
if (!match && !stream.isTTY)
return;
// Use an override from PLAYWRIGHT_TTY or the real value for each.
return {
columns: match?.[1] ? +match[1] : (stream.isTTY ? stream.columns : 0),
rows: match?.[2] ? +match[2] : (stream.isTTY ? stream.rows : 0),
colorDepth: match?.[3] ? +match[3] : (stream.isTTY ? (stream.getColorDepth?.() || 8) : 8),
};
}
export const stdoutTTY = getTTYParams(process.stdout);
export const stderrTTY = getTTYParams(process.stderr);
let useColors = !!stdoutTTY;
if (process.env.DEBUG_COLORS === '0'
|| process.env.DEBUG_COLORS === 'false'
|| process.env.FORCE_COLOR === '0'
|| process.env.FORCE_COLOR === 'false')
useColors = false;
else if (process.env.DEBUG_COLORS || process.env.FORCE_COLOR)
useColors = true;
export const colors = useColors ? realColors : {
bold: (t: string) => t,
cyan: (t: string) => t,
dim: (t: string) => t,
gray: (t: string) => t,
green: (t: string) => t,
red: (t: string) => t,
yellow: (t: string) => t,
enabled: false,
};
export function setTTYParams(stream: WriteStream, params: TTYParams | undefined) {
if (!params) {
stream.isTTY = false;
stream.rows = 0;
stream.columns = 0;
stream.getColorDepth = () => 8;
return;
}
stream.isTTY = true;
if (params.rows)
stream.rows = params.rows;
if (params.columns)
stream.columns = params.columns;
stream.getColorDepth = () => params.colorDepth;
stream.hasColors = ((count = 16) => {
// count is optional and the first argument may actually be env.
if (typeof count !== 'number')
count = 16;
return count <= 2 ** params.colorDepth;
})as any;
// Stubs for the rest of the methods to avoid exceptions in user code.
stream.clearLine = (dir: any, callback?: () => void) => {
callback?.();
return true;
};
stream.clearScreenDown = (callback?: () => void) => {
callback?.();
return true;
};
(stream as any).cursorTo = (x: number, y?: number | (() => void), callback?: () => void) => {
if (callback)
callback();
else if (y instanceof Function)
y();
return true;
};
stream.moveCursor = (dx: number, dy: number, callback?: () => void) => {
callback?.();
return true;
};
stream.getWindowSize = () => [stream.columns, stream.rows];
}
export function resizeTTY(columns: number, rows: number) {
if (stdoutTTY) {
process.stdout.columns = stdoutTTY.columns = columns;
process.stdout.rows = stdoutTTY.rows = rows;
}
if (stderrTTY) {
process.stderr.columns = stderrTTY.rows = rows;
process.stderr.rows = stderrTTY.columns = columns;
}
}

View file

@ -14,11 +14,14 @@
* limitations under the License.
*/
import { colors as realColors, ms as milliseconds, parseStackTraceLine } from 'playwright-core/lib/utilsBundle';
import { ms as milliseconds, parseStackTraceLine } from 'playwright-core/lib/utilsBundle';
import path from 'path';
import type { FullConfig, TestCase, Suite, TestResult, TestError, FullResult, TestStep, Location } from '../../types/testReporter';
import { getPackageManagerExecCommand } from 'playwright-core/lib/utils';
import type { ReporterV2 } from './reporterV2';
import { colors, stdoutTTY } from '../common/tty';
export { colors } from '../common/tty';
export type TestResultOutput = { chunk: string | Buffer, type: 'stdout' | 'stderr' };
export const kOutputSymbol = Symbol('output');
@ -44,28 +47,6 @@ type TestSummary = {
fatalErrors: TestError[];
};
export const isTTY = !!process.env.PWTEST_TTY_WIDTH || process.stdout.isTTY;
export const ttyWidth = process.env.PWTEST_TTY_WIDTH ? parseInt(process.env.PWTEST_TTY_WIDTH, 10) : process.stdout.columns || 0;
let useColors = isTTY;
if (process.env.DEBUG_COLORS === '0'
|| process.env.DEBUG_COLORS === 'false'
|| process.env.FORCE_COLOR === '0'
|| process.env.FORCE_COLOR === 'false')
useColors = false;
else if (process.env.DEBUG_COLORS || process.env.FORCE_COLOR)
useColors = true;
export const colors = useColors ? realColors : {
bold: (t: string) => t,
cyan: (t: string) => t,
dim: (t: string) => t,
gray: (t: string) => t,
green: (t: string) => t,
red: (t: string) => t,
yellow: (t: string) => t,
enabled: false,
};
export class BaseReporter implements ReporterV2 {
config!: FullConfig;
suite!: Suite;
@ -145,11 +126,11 @@ export class BaseReporter implements ReporterV2 {
}
protected fitToScreen(line: string, prefix?: string): string {
if (!ttyWidth) {
if (!stdoutTTY?.columns) {
// Guard against the case where we cannot determine available width.
return line;
}
return fitToWidth(line, ttyWidth, prefix);
return fitToWidth(line, stdoutTTY.columns, prefix);
}
protected generateStartingMessage() {
@ -491,7 +472,7 @@ export function formatError(error: TestError, highlightCode: boolean): ErrorDeta
export function separator(text: string = ''): string {
if (text)
text += ' ';
const columns = Math.min(100, ttyWidth || 100);
const columns = Math.min(100, stdoutTTY?.columns || 100);
return text + colors.dim('─'.repeat(Math.max(0, columns - text.length)));
}

View file

@ -15,8 +15,9 @@
*/
import { ms as milliseconds } from 'playwright-core/lib/utilsBundle';
import { colors, BaseReporter, formatError, formatTestTitle, isTTY, stepSuffix, stripAnsiEscapes, ttyWidth } from './base';
import { colors, BaseReporter, formatError, formatTestTitle, stepSuffix, stripAnsiEscapes } from './base';
import type { FullResult, Suite, TestCase, TestError, TestResult, TestStep } from '../../types/testReporter';
import { stdoutTTY } from '../common/tty';
// Allow it in the Visual Studio Code Terminal and the new Windows Terminal
const DOES_NOT_SUPPORT_UTF8_IN_TERMINAL = process.platform === 'win32' && process.env.TERM_PROGRAM !== 'vscode' && !process.env.WT_SESSION;
@ -35,7 +36,7 @@ class ListReporter extends BaseReporter {
constructor(options: { omitFailures?: boolean, printSteps?: boolean } = {}) {
super(options);
this._printSteps = isTTY && (options.printSteps || !!process.env.PW_TEST_DEBUG_REPORTERS_PRINT_STEPS);
this._printSteps = !!stdoutTTY && (options.printSteps || !!process.env.PW_TEST_DEBUG_REPORTERS_PRINT_STEPS);
}
override printsToStdio() {
@ -53,7 +54,7 @@ class ListReporter extends BaseReporter {
override onTestBegin(test: TestCase, result: TestResult) {
super.onTestBegin(test, result);
if (!isTTY)
if (!stdoutTTY)
return;
this._maybeWriteNewLine();
const index = String(this._resultIndex.size + 1);
@ -80,7 +81,7 @@ class ListReporter extends BaseReporter {
return;
const testIndex = this._resultIndex.get(result) || '';
if (!this._printSteps) {
if (isTTY)
if (stdoutTTY)
this._updateLine(this._testRows.get(test)!, colors.dim(formatTestTitle(this.config, test, step)) + this._retrySuffix(result), this._testPrefix(testIndex, ''));
return;
}
@ -90,7 +91,7 @@ class ListReporter extends BaseReporter {
const stepIndex = `${testIndex}.${ordinal}`;
this._stepIndex.set(step, stepIndex);
if (isTTY) {
if (stdoutTTY) {
this._maybeWriteNewLine();
this._stepRows.set(step, this._lastRow);
const prefix = this._testPrefix(stepIndex, '');
@ -106,7 +107,7 @@ class ListReporter extends BaseReporter {
const testIndex = this._resultIndex.get(result) || '';
if (!this._printSteps) {
if (isTTY)
if (stdoutTTY)
this._updateLine(this._testRows.get(test)!, colors.dim(formatTestTitle(this.config, test, step.parent)) + this._retrySuffix(result), this._testPrefix(testIndex, ''));
return;
}
@ -133,7 +134,7 @@ class ListReporter extends BaseReporter {
private _updateLineCountAndNewLineFlagForOutput(text: string) {
this._needNewLine = text[text.length - 1] !== '\n';
if (!ttyWidth)
if (!stdoutTTY?.columns)
return;
for (const ch of text) {
if (ch === '\n') {
@ -142,7 +143,7 @@ class ListReporter extends BaseReporter {
continue;
}
++this._lastColumn;
if (this._lastColumn > ttyWidth) {
if (this._lastColumn > stdoutTTY.columns) {
this._lastColumn = 0;
++this._lastRow;
}
@ -192,7 +193,7 @@ class ListReporter extends BaseReporter {
}
private _updateOrAppendLine(row: number, text: string, prefix: string) {
if (isTTY) {
if (stdoutTTY) {
this._updateLine(row, text, prefix);
} else {
this._maybeWriteNewLine();
@ -229,8 +230,6 @@ class ListReporter extends BaseReporter {
// Go down if needed.
if (row !== this._lastRow)
process.stdout.write(`\u001B[${this._lastRow - row}E`);
if (process.env.PWTEST_TTY_WIDTH)
process.stdout.write('\n'); // For testing.
}
private _testPrefix(index: string, statusMark: string) {

View file

@ -22,6 +22,7 @@ import type { ProtocolResponse } from '../common/process';
import { execArgvWithExperimentalLoaderOptions } from '../transform/esmUtils';
import { assert } from 'playwright-core/lib/utils';
import { esmLoaderRegistered } from '../common/esmLoaderHost';
import { stderrTTY, stdoutTTY } from '../common/tty';
export type ProcessExitData = {
unexpectedly: boolean;
@ -111,20 +112,7 @@ export class ProcessHost extends EventEmitter {
if (error)
return error;
const processParams: ProcessInitParams = {
stdoutParams: {
rows: process.stdout.rows,
columns: process.stdout.columns,
colorDepth: process.stdout.getColorDepth?.() || 8
},
stderrParams: {
rows: process.stderr.rows,
columns: process.stderr.columns,
colorDepth: process.stderr.getColorDepth?.() || 8
},
processName: this._processName
};
const processParams: ProcessInitParams = { stdoutTTY, stderrTTY, processName: this._processName };
this.send({
method: '__init__', params: {
processParams,

View file

@ -39,6 +39,7 @@ import { loadConfig, resolveConfigFile, restartWithExperimentalTsEsm } from '../
import { webServerPluginsForConfig } from '../plugins/webServerPlugin';
import type { TraceViewerRedirectOptions, TraceViewerServerOptions } from 'playwright-core/lib/server/trace/viewer/traceViewer';
import type { TestRunnerPluginRegistration } from '../plugins';
import { resizeTTY } from '../common/tty';
class TestServer {
private _configFile: string | undefined;
@ -120,10 +121,7 @@ class TestServerDispatcher implements TestServerInterface {
}
async resizeTerminal(params: Parameters<TestServerInterface['resizeTerminal']>[0]): ReturnType<TestServerInterface['resizeTerminal']> {
process.stdout.columns = params.cols;
process.stdout.rows = params.rows;
process.stderr.columns = params.cols;
process.stderr.columns = params.rows;
resizeTTY(params.cols, params.rows);
}
async checkBrowsers(): Promise<{ hasBrowsers: boolean; }> {

View file

@ -441,7 +441,7 @@ test('merge into list report by default', async ({ runInlineTest, mergeReports }
const reportFiles = await fs.promises.readdir(reportDir);
reportFiles.sort();
expect(reportFiles).toEqual(['report-1.zip', 'report-2.zip', 'report-3.zip']);
const { exitCode, output } = await mergeReports(reportDir, { PW_TEST_DEBUG_REPORTERS: '1', PW_TEST_DEBUG_REPORTERS_PRINT_STEPS: '1', PWTEST_TTY_WIDTH: '80' }, { additionalArgs: ['--reporter', 'list'] });
const { exitCode, output } = await mergeReports(reportDir, { PW_TEST_DEBUG_REPORTERS: '1', PW_TEST_DEBUG_REPORTERS_PRINT_STEPS: '1', PLAYWRIGHT_TTY: '80x25x8' }, { additionalArgs: ['--reporter', 'list'] });
expect(exitCode).toBe(0);
const text = stripAnsi(output);

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
import { test, expect } from './playwright-test-fixtures';
import { test, expect, stripAnsi } from './playwright-test-fixtures';
const DOES_NOT_SUPPORT_UTF8_IN_TERMINAL = process.platform === 'win32' && process.env.TERM_PROGRAM !== 'vscode' && !process.env.WT_SESSION;
const POSITIVE_STATUS_MARK = DOES_NOT_SUPPORT_UTF8_IN_TERMINAL ? 'ok' : '✓ ';
@ -70,7 +70,7 @@ for (const useIntermediateMergeReport of [false, true] as const) {
});
});
`,
}, { reporter: 'list' }, { PW_TEST_DEBUG_REPORTERS: '1', PW_TEST_DEBUG_REPORTERS_PRINT_STEPS: '1', PWTEST_TTY_WIDTH: '80' });
}, { reporter: 'list' }, { PW_TEST_DEBUG_REPORTERS: '1', PW_TEST_DEBUG_REPORTERS_PRINT_STEPS: '1', PLAYWRIGHT_TTY: '80' });
const text = result.output;
const lines = text.split('\n').filter(l => l.match(/^\d :/)).map(l => l.replace(/[.\d]+m?s/, 'Xms'));
lines.pop(); // Remove last item that contains [v] and time in ms.
@ -105,7 +105,7 @@ for (const useIntermediateMergeReport of [false, true] as const) {
await test.step('inner 2.2', async () => {});
});
});`,
}, { reporter: 'list' }, { PW_TEST_DEBUG_REPORTERS: '1', PWTEST_TTY_WIDTH: '80' });
}, { reporter: 'list' }, { PW_TEST_DEBUG_REPORTERS: '1', PLAYWRIGHT_TTY: '80x25' });
const text = result.output;
const lines = text.split('\n').filter(l => l.match(/^\d :/)).map(l => l.replace(/[.\d]+m?s/, 'Xms'));
lines.pop(); // Remove last item that contains [v] and time in ms.
@ -135,7 +135,7 @@ for (const useIntermediateMergeReport of [false, true] as const) {
console.log('a'.repeat(80) + 'b'.repeat(20));
});
`,
}, { reporter: 'list' }, { PWTEST_TTY_WIDTH: TTY_WIDTH + '' });
}, { reporter: 'list' }, { PLAYWRIGHT_TTY: TTY_WIDTH + '' });
const renderedText = simpleAnsiRenderer(result.rawOutput, TTY_WIDTH);
if (process.platform === 'win32')
@ -154,7 +154,7 @@ for (const useIntermediateMergeReport of [false, true] as const) {
expect(testInfo.retry).toBe(1);
});
`,
}, { reporter: 'list', retries: '1' }, { PW_TEST_DEBUG_REPORTERS: '1', PWTEST_TTY_WIDTH: '80' });
}, { reporter: 'list', retries: '1' }, { PW_TEST_DEBUG_REPORTERS: '1', PLAYWRIGHT_TTY: '80' });
const text = result.output;
const lines = text.split('\n').filter(l => l.startsWith('0 :') || l.startsWith('1 :')).map(l => l.replace(/\d+(\.\d+)?m?s/, 'XXms'));
@ -185,10 +185,10 @@ for (const useIntermediateMergeReport of [false, true] as const) {
test.skip('skipped very long name', async () => {
});
`,
}, { reporter: 'list', retries: 0 }, { PWTEST_TTY_WIDTH: '50' });
}, { reporter: 'list', retries: 0 }, { PLAYWRIGHT_TTY: '50x20' });
expect(result.exitCode).toBe(1);
const lines = result.output.split('\n').slice(3, 11);
const lines = result.rawOutput.split('\n').map(line => line.split('\x1B[22m\x1B[1E')).flat().map(line => stripAnsi(line)).filter(line => line.trim()).slice(1, 9);
expect(lines.every(line => line.length <= 50)).toBe(true);
expect(lines[0]).toBe(` 1 …a.test.ts:3:15 failure in very long name`);

View file

@ -89,7 +89,7 @@ test('should support console colors', async ({ runInlineTest }) => {
console.error({ b: false, n: 123, s: 'abc' });
});
`
});
}, {}, { PLAYWRIGHT_TTY: '80' });
expect(result.output).toContain(`process.stdout.isTTY = true`);
expect(result.output).toContain(`process.stderr.isTTY = true`);
// The output should have colors.
@ -102,17 +102,22 @@ test('should override hasColors and getColorDepth', async ({ runInlineTest }) =>
'a.spec.js': `
import { test, expect } from '@playwright/test';
test('console log', () => {
console.log('process.stdout.hasColors(1) = ' + process.stdout.hasColors(1));
console.log('process.stderr.hasColors(1) = ' + process.stderr.hasColors(1));
console.log('process.stdout.getColorDepth() > 0 = ' + (process.stdout.getColorDepth() > 0));
console.log('process.stderr.getColorDepth() > 0 = ' + (process.stderr.getColorDepth() > 0));
expect(process.stdout.hasColors(1)).toBe(true);
expect(process.stdout.isTTY).toBe(true);
expect(process.stdout.columns).toBe(60);
expect(process.stdout.rows).toBe(15);
expect(process.stdout.getColorDepth()).toBe(16);
expect(process.stderr.hasColors(1)).toBe(true);
expect(process.stderr.isTTY).toBe(true);
expect(process.stderr.columns).toBe(60);
expect(process.stderr.rows).toBe(15);
expect(process.stderr.getColorDepth()).toBe(16);
});
`
});
expect(result.output).toContain(`process.stdout.hasColors(1) = true`);
expect(result.output).toContain(`process.stderr.hasColors(1) = true`);
expect(result.output).toContain(`process.stdout.getColorDepth() > 0 = true`);
expect(result.output).toContain(`process.stderr.getColorDepth() > 0 = true`);
}, {}, { PLAYWRIGHT_TTY: '60x15x16' });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});
test('should not throw type error when using assert', async ({ runInlineTest }) => {
@ -152,6 +157,25 @@ test('should provide stubs for tty.WriteStream methods on process.stdout/stderr'
checkMethods(process.stderr);
});
`
});
}, {}, { PLAYWRIGHT_TTY: '80' });
expect(result.exitCode).toBe(0);
});
test('should respect PLAYWRIGHT_TTY=0', async ({ runInlineTest }) => {
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29422' });
const result = await runInlineTest({
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('passes', () => {
expect(process.stdout.isTTY).toBe(false);
expect(process.stdout.columns).toBe(0);
expect(process.stdout.rows).toBe(0);
expect(process.stderr.isTTY).toBe(false);
expect(process.stderr.columns).toBe(0);
expect(process.stderr.rows).toBe(0);
});
`
}, {}, { PLAYWRIGHT_TTY: '0' });
expect(result.exitCode).toBe(0);
});