From cce895babccfc8b6b8b808099b4720c7412e25c4 Mon Sep 17 00:00:00 2001 From: Suparva Date: Tue, 6 Oct 2026 13:18:08 +0530 Subject: [PATCH] fix(list reporter): decode multi-byte stdio chunks correctly across writes References #43079. In #42886, ListReporter._dumpToStdio decoded stdio chunks with chunk.toString('utf-8'). When multi-byte characters are split across chunk writes from worker processes, decoding chunk-by-chunk corrupted them into replacement characters. Use StringDecoder from string_decoder per worker/stream to buffer incomplete multi-byte sequences across chunk boundaries, ensuring clean line-prefixing and uncorrupted terminal output. --- packages/playwright/src/reporters/list.ts | 55 ++++++++++++++++++--- tests/playwright-test/reporter-list.spec.ts | 35 +++++++++++++ 2 files changed, 83 insertions(+), 7 deletions(-) diff --git a/packages/playwright/src/reporters/list.ts b/packages/playwright/src/reporters/list.ts index a6a31bbf50274..ab59bc2bbf21c 100644 --- a/packages/playwright/src/reporters/list.ts +++ b/packages/playwright/src/reporters/list.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { StringDecoder } from 'string_decoder'; + import { msToString } from '@isomorphic/formatUtils'; import { getAsBooleanFromENV } from '@utils/env'; @@ -42,6 +44,7 @@ class ListReporter extends TerminalReporter { private _printWorkerIndex: boolean; private _failureIndex = new Map(); private _paused = new Set(); + private _stringDecoders = new Map(); constructor(options?: ListReporterOptions & CommonReporterOptions & TerminalReporterOptions) { const printFailuresInline = getAsBooleanFromENV('PLAYWRIGHT_LIST_PRINT_FAILURES_INLINE', options?.printFailuresInline); @@ -167,11 +170,28 @@ class ListReporter extends TerminalReporter { private _dumpToStdio(chunk: string | Buffer, stream: NodeJS.WriteStream, result: TestResult | undefined) { if (this.config.quiet) return; - let text = chunk.toString('utf-8'); - if (result) - text = this._prefixLines(text, this._workerPrefix(result), !this._needNewLine); - this._updateLineCountAndNewLineFlagForOutput(text); - stream.write(text); + if (chunk.length === 0) { + this._updateLineCountAndNewLineFlagForOutput(''); + return; + } + const decoder = this._getStringDecoder(stream, result); + const text = decoder.write(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); + if (!text) + return; + const prefix = result ? this._workerPrefix(result) : ''; + const prefixedText = this._prefixLines(text, prefix, !this._needNewLine); + this._updateLineCountAndNewLineFlagForOutput(prefixedText); + stream.write(prefixedText); + } + + private _getStringDecoder(stream: NodeJS.WriteStream, result: TestResult | undefined): StringDecoder { + const key = `${result?.workerIndex ?? -1}:${stream === this.screen.stdout ? 'out' : 'err'}`; + let decoder = this._stringDecoders.get(key); + if (!decoder) { + decoder = new StringDecoder('utf-8'); + this._stringDecoders.set(key, decoder); + } + return decoder; } private _prefixLines(text: string, prefix: string, atLineStart: boolean) { @@ -305,10 +325,14 @@ class ListReporter extends TerminalReporter { } private _workerPrefix(result: TestResult) { + return this._workerPrefixFromIndex(result.workerIndex); + } + + private _workerPrefixFromIndex(workerIndex: number | undefined) { if (!this._printWorkerIndex) return ''; - const workerIndex = result.workerIndex >= 0 ? String(result.workerIndex) : ' '; - return this.screen.colors.dim(`[${workerIndex}]`) + ' '; + const indexStr = workerIndex !== undefined && workerIndex >= 0 ? String(workerIndex) : ' '; + return this.screen.colors.dim(`[${indexStr}]`) + ' '; } private _testPrefix(result: TestResult, index: string, statusMark: string) { @@ -330,6 +354,23 @@ class ListReporter extends TerminalReporter { } override async onEnd(result: FullResult) { + for (const [key, decoder] of this._stringDecoders) { + const remaining = decoder.end(); + if (remaining) { + const stream = key.endsWith(':out') ? this.screen.stdout : this.screen.stderr; + if (this._printWorkerIndex) { + const workerIndex = Number(key.split(':')[0]); + const prefix = this._workerPrefixFromIndex(workerIndex); + const prefixed = this._prefixLines(remaining, prefix, !this._needNewLine); + this._updateLineCountAndNewLineFlagForOutput(prefixed); + stream.write(prefixed); + } else { + this._updateLineCountAndNewLineFlagForOutput(remaining); + stream.write(remaining); + } + } + } + this._stringDecoders.clear(); await super.onEnd(result); this.screen.stdout.write('\n'); this.epilogue(!this._printFailuresInline); diff --git a/tests/playwright-test/reporter-list.spec.ts b/tests/playwright-test/reporter-list.spec.ts index c2f78de3abace..d25def1d655da 100644 --- a/tests/playwright-test/reporter-list.spec.ts +++ b/tests/playwright-test/reporter-list.spec.ts @@ -443,6 +443,41 @@ for (const useIntermediateMergeReport of [false, true] as const) { expect(lines[firstIndex + i]).toContain(expected[i]); }); + test('print stdio with split multi-byte characters', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('passes', async ({}) => { + await new Promise(resolve => process.stdout.write(Buffer.from([0xe2, 0x82]), () => resolve())); + await new Promise(resolve => process.stdout.write(Buffer.from([0xac, 0x0a]), () => resolve())); + }); + `, + }, { reporter: 'list' }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + expect(result.output).toContain('€'); + expect(result.output).not.toContain('�'); + }); + + test('print worker index with split multi-byte characters', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { reporter: [['list', { printWorkerIndex: true }]] }; + `, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('passes', async ({}) => { + await new Promise(resolve => process.stdout.write(Buffer.from([0xe2, 0x82]), () => resolve())); + await new Promise(resolve => process.stdout.write(Buffer.from([0xac, 0x0a]), () => resolve())); + }); + `, + }, { workers: 1 }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + expect(result.output).toContain('[0] €'); + expect(result.output).not.toContain('�'); + }); + test('should update test status row only when TTY has not scrolled', async ({ runInlineTest }) => { const result = await runInlineTest({ 'a.test.ts': `