feat: Add fileOnLargeOutput outputMode option (#339)#343
feat: Add fileOnLargeOutput outputMode option (#339)#343cxx5208 wants to merge 3 commits intomicrosoft:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new outputMode: "fileOnLargeOutput" configuration intended to route small CLI output to stdout and large output to a file, with a configurable maxStdOutputSize threshold.
Changes:
- Document
fileOnLargeOutputandmaxStdOutputSizein the README config schema. - Implement
fileOnLargeOutputbehavior inplaywright-cli.jsby buffering output and writing to a file when above a threshold. - Add a
TEST_RESULTS.mddocument describing manual test coverage and outcomes.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 11 comments.
| File | Description |
|---|---|
| TEST_RESULTS.md | Adds a manual test report for the new output mode and threshold behavior. |
| README.md | Updates the config schema documentation to include fileOnLargeOutput and maxStdOutputSize. |
| playwright-cli.js | Adds logic to detect fileOnLargeOutput, capture output, and conditionally write to disk. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| function getOutputMode() { | ||
| try { | ||
| const configPath = path.join(process.cwd(), '.playwright', 'cli.config.json'); | ||
| if (fs.existsSync(configPath)) { | ||
| const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); | ||
| return config.outputMode || null; | ||
| } | ||
| } catch {} | ||
| return null; | ||
| } | ||
|
|
||
| function getMaxStdOutputSize() { | ||
| try { | ||
| const configPath = path.join(process.cwd(), '.playwright', 'cli.config.json'); | ||
| if (fs.existsSync(configPath)) { | ||
| const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); | ||
| if (config.maxStdOutputSize !== undefined) { | ||
| return config.maxStdOutputSize; | ||
| } | ||
| } | ||
| } catch {} | ||
| return DEFAULT_MAX_OUTPUT_SIZE; | ||
| } | ||
|
|
||
| function getOutputDir() { | ||
| try { | ||
| const configPath = path.join(process.cwd(), '.playwright', 'cli.config.json'); | ||
| if (fs.existsSync(configPath)) { | ||
| const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); | ||
| if (config.outputDir) { | ||
| return config.outputDir; | ||
| } | ||
| } | ||
| } catch {} | ||
| return path.join(os.tmpdir(), 'playwright-cli-output'); | ||
| } | ||
|
|
||
| async function main() { | ||
| const outputMode = getOutputMode(); | ||
|
|
||
| if (outputMode !== 'fileOnLargeOutput') { | ||
| await program({ embedderVersion: packageJson.version }); | ||
| return; | ||
| } | ||
|
|
||
| const maxSize = getMaxStdOutputSize(); | ||
| const outputDir = getOutputDir(); |
There was a problem hiding this comment.
getOutputMode() (and related helpers) always reads .playwright/cli.config.json from process.cwd(), which means outputMode: "fileOnLargeOutput" will not work when the user supplies a different config via --config (or any other config resolution the underlying program() supports). Consider reusing the same config resolution logic as program() or parsing process.argv for the selected config path before deciding whether to wrap output.
| function getOutputMode() { | |
| try { | |
| const configPath = path.join(process.cwd(), '.playwright', 'cli.config.json'); | |
| if (fs.existsSync(configPath)) { | |
| const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); | |
| return config.outputMode || null; | |
| } | |
| } catch {} | |
| return null; | |
| } | |
| function getMaxStdOutputSize() { | |
| try { | |
| const configPath = path.join(process.cwd(), '.playwright', 'cli.config.json'); | |
| if (fs.existsSync(configPath)) { | |
| const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); | |
| if (config.maxStdOutputSize !== undefined) { | |
| return config.maxStdOutputSize; | |
| } | |
| } | |
| } catch {} | |
| return DEFAULT_MAX_OUTPUT_SIZE; | |
| } | |
| function getOutputDir() { | |
| try { | |
| const configPath = path.join(process.cwd(), '.playwright', 'cli.config.json'); | |
| if (fs.existsSync(configPath)) { | |
| const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); | |
| if (config.outputDir) { | |
| return config.outputDir; | |
| } | |
| } | |
| } catch {} | |
| return path.join(os.tmpdir(), 'playwright-cli-output'); | |
| } | |
| async function main() { | |
| const outputMode = getOutputMode(); | |
| if (outputMode !== 'fileOnLargeOutput') { | |
| await program({ embedderVersion: packageJson.version }); | |
| return; | |
| } | |
| const maxSize = getMaxStdOutputSize(); | |
| const outputDir = getOutputDir(); | |
| function getCliConfigPath() { | |
| for (let i = 2; i < process.argv.length; ++i) { | |
| const arg = process.argv[i]; | |
| if (arg === '--config' && i + 1 < process.argv.length) { | |
| return path.resolve(process.cwd(), process.argv[i + 1]); | |
| } | |
| if (arg.startsWith('--config=')) { | |
| return path.resolve(process.cwd(), arg.substring('--config='.length)); | |
| } | |
| } | |
| return path.join(process.cwd(), '.playwright', 'cli.config.json'); | |
| } | |
| function readCliConfig() { | |
| try { | |
| const configPath = getCliConfigPath(); | |
| if (fs.existsSync(configPath)) | |
| return JSON.parse(fs.readFileSync(configPath, 'utf8')); | |
| } catch {} | |
| return null; | |
| } | |
| function getOutputMode(config) { | |
| return config?.outputMode || null; | |
| } | |
| function getMaxStdOutputSize(config) { | |
| if (config?.maxStdOutputSize !== undefined) | |
| return config.maxStdOutputSize; | |
| return DEFAULT_MAX_OUTPUT_SIZE; | |
| } | |
| function getOutputDir(config) { | |
| if (config?.outputDir) | |
| return config.outputDir; | |
| return path.join(os.tmpdir(), 'playwright-cli-output'); | |
| } | |
| async function main() { | |
| const cliConfig = readCliConfig(); | |
| const outputMode = getOutputMode(cliConfig); | |
| if (outputMode !== 'fileOnLargeOutput') { | |
| await program({ embedderVersion: packageJson.version }); | |
| return; | |
| } | |
| const maxSize = getMaxStdOutputSize(cliConfig); | |
| const outputDir = getOutputDir(cliConfig); |
| let outputBuffer = ''; | ||
|
|
||
| console.log = (...args) => { | ||
| outputBuffer += args.map(a => String(a)).join(' ') + '\n'; | ||
| }; | ||
|
|
||
| try { | ||
| await program({ embedderVersion: packageJson.version }); | ||
| } finally { | ||
| console.log = originalLog; | ||
| } | ||
|
|
||
| const outputSize = Buffer.byteLength(outputBuffer, 'utf8'); | ||
|
|
||
| if (outputSize > maxSize) { | ||
| const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); | ||
| const fileName = `output-${timestamp}.txt`; | ||
| const filePath = path.join(outputDir, fileName); | ||
|
|
||
| fs.writeFileSync(filePath, outputBuffer, 'utf8'); | ||
| originalLog(`\nOutput exceeded ${maxSize} bytes (${outputSize} bytes). Saved to: ${filePath}`); | ||
| } else { | ||
| originalLog(outputBuffer); |
There was a problem hiding this comment.
This mode buffers all console output into outputBuffer and only prints/writes it after program() completes. That changes CLI behavior substantially (no streaming output) and can cause large runs to consume a lot of memory (potentially OOM) before the threshold check runs. To avoid this, consider a streaming approach (e.g., tee to a temp file while counting bytes, or redirect to file once the threshold is exceeded).
| let outputBuffer = ''; | |
| console.log = (...args) => { | |
| outputBuffer += args.map(a => String(a)).join(' ') + '\n'; | |
| }; | |
| try { | |
| await program({ embedderVersion: packageJson.version }); | |
| } finally { | |
| console.log = originalLog; | |
| } | |
| const outputSize = Buffer.byteLength(outputBuffer, 'utf8'); | |
| if (outputSize > maxSize) { | |
| const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); | |
| const fileName = `output-${timestamp}.txt`; | |
| const filePath = path.join(outputDir, fileName); | |
| fs.writeFileSync(filePath, outputBuffer, 'utf8'); | |
| originalLog(`\nOutput exceeded ${maxSize} bytes (${outputSize} bytes). Saved to: ${filePath}`); | |
| } else { | |
| originalLog(outputBuffer); | |
| const tempFilePath = path.join(outputDir, `output-${process.pid}-${Date.now()}.tmp`); | |
| const tempFd = fs.openSync(tempFilePath, 'w'); | |
| let outputSize = 0; | |
| console.log = (...args) => { | |
| const line = args.map(a => String(a)).join(' ') + '\n'; | |
| outputSize += Buffer.byteLength(line, 'utf8'); | |
| fs.writeSync(tempFd, line, null, 'utf8'); | |
| }; | |
| try { | |
| await program({ embedderVersion: packageJson.version }); | |
| } finally { | |
| console.log = originalLog; | |
| fs.closeSync(tempFd); | |
| } | |
| if (outputSize > maxSize) { | |
| const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); | |
| const fileName = `output-${timestamp}.txt`; | |
| const filePath = path.join(outputDir, fileName); | |
| fs.renameSync(tempFilePath, filePath); | |
| originalLog(`\nOutput exceeded ${maxSize} bytes (${outputSize} bytes). Saved to: ${filePath}`); | |
| } else { | |
| const output = fs.readFileSync(tempFilePath, 'utf8'); | |
| fs.unlinkSync(tempFilePath); | |
| originalLog(output); |
| const originalLog = console.log; | ||
| let outputBuffer = ''; | ||
|
|
||
| console.log = (...args) => { | ||
| outputBuffer += args.map(a => String(a)).join(' ') + '\n'; | ||
| }; |
There was a problem hiding this comment.
Only console.log is wrapped here, so output written via process.stdout.write, console.error, or other logging APIs will bypass the size accounting and may still flood the terminal even in fileOnLargeOutput mode. If the intent is to gate stdout/stderr output size, consider wrapping process.stdout.write/process.stderr.write (and/or console.error) instead of only console.log.
| const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); | ||
| if (config.maxStdOutputSize !== undefined) { | ||
| return config.maxStdOutputSize; | ||
| } | ||
| } | ||
| } catch {} | ||
| return DEFAULT_MAX_OUTPUT_SIZE; |
There was a problem hiding this comment.
maxStdOutputSize is returned without validation. If the config contains null, a string, NaN, or a negative value, the comparison outputSize > maxSize will behave unexpectedly (e.g., null coerces to 0). Consider validating/coercing to a finite non-negative number and falling back to the default when invalid.
| function getOutputMode() { | ||
| try { | ||
| const configPath = path.join(process.cwd(), '.playwright', 'cli.config.json'); | ||
| if (fs.existsSync(configPath)) { | ||
| const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); | ||
| return config.outputMode || null; | ||
| } | ||
| } catch {} | ||
| return null; | ||
| } | ||
|
|
||
| function getMaxStdOutputSize() { | ||
| try { | ||
| const configPath = path.join(process.cwd(), '.playwright', 'cli.config.json'); | ||
| if (fs.existsSync(configPath)) { | ||
| const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); | ||
| if (config.maxStdOutputSize !== undefined) { | ||
| return config.maxStdOutputSize; | ||
| } | ||
| } | ||
| } catch {} | ||
| return DEFAULT_MAX_OUTPUT_SIZE; | ||
| } | ||
|
|
||
| function getOutputDir() { | ||
| try { | ||
| const configPath = path.join(process.cwd(), '.playwright', 'cli.config.json'); | ||
| if (fs.existsSync(configPath)) { | ||
| const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); | ||
| if (config.outputDir) { | ||
| return config.outputDir; | ||
| } | ||
| } | ||
| } catch {} |
There was a problem hiding this comment.
The empty catch {} blocks in the config readers will silently ignore JSON parse errors or filesystem errors, which makes misconfiguration hard to diagnose (the CLI will just fall back to defaults). Consider emitting a short warning (or at least handling JSON parse errors explicitly) when the config exists but cannot be parsed.
| function getOutputMode() { | |
| try { | |
| const configPath = path.join(process.cwd(), '.playwright', 'cli.config.json'); | |
| if (fs.existsSync(configPath)) { | |
| const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); | |
| return config.outputMode || null; | |
| } | |
| } catch {} | |
| return null; | |
| } | |
| function getMaxStdOutputSize() { | |
| try { | |
| const configPath = path.join(process.cwd(), '.playwright', 'cli.config.json'); | |
| if (fs.existsSync(configPath)) { | |
| const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); | |
| if (config.maxStdOutputSize !== undefined) { | |
| return config.maxStdOutputSize; | |
| } | |
| } | |
| } catch {} | |
| return DEFAULT_MAX_OUTPUT_SIZE; | |
| } | |
| function getOutputDir() { | |
| try { | |
| const configPath = path.join(process.cwd(), '.playwright', 'cli.config.json'); | |
| if (fs.existsSync(configPath)) { | |
| const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); | |
| if (config.outputDir) { | |
| return config.outputDir; | |
| } | |
| } | |
| } catch {} | |
| function readCliConfig() { | |
| const configPath = path.join(process.cwd(), '.playwright', 'cli.config.json'); | |
| if (!fs.existsSync(configPath)) { | |
| return null; | |
| } | |
| try { | |
| return JSON.parse(fs.readFileSync(configPath, 'utf8')); | |
| } catch (e) { | |
| console.warn(`Warning: Failed to read Playwright CLI config at ${configPath}: ${e.message}`); | |
| return null; | |
| } | |
| } | |
| function getOutputMode() { | |
| const config = readCliConfig(); | |
| return config ? config.outputMode || null : null; | |
| } | |
| function getMaxStdOutputSize() { | |
| const config = readCliConfig(); | |
| if (config && config.maxStdOutputSize !== undefined) { | |
| return config.maxStdOutputSize; | |
| } | |
| return DEFAULT_MAX_OUTPUT_SIZE; | |
| } | |
| function getOutputDir() { | |
| const config = readCliConfig(); | |
| if (config && config.outputDir) { | |
| return config.outputDir; | |
| } |
| } | ||
|
|
||
| main().catch(e => { | ||
| console.error(e.message); |
There was a problem hiding this comment.
main().catch(e => console.error(e.message)) can hide useful debugging details (no stack trace) and will print undefined if a non-Error value is thrown. Consider logging the full error (or e?.stack ?? e) to keep diagnostics comparable to the previous direct program() execution.
| console.error(e.message); | |
| console.error(e?.stack ?? e); |
| async function main() { | ||
| const outputMode = getOutputMode(); | ||
|
|
||
| if (outputMode !== 'fileOnLargeOutput') { | ||
| await program({ embedderVersion: packageJson.version }); | ||
| return; | ||
| } |
There was a problem hiding this comment.
There are existing Playwright integration tests for the CLI, but none cover the new outputMode: "fileOnLargeOutput" / maxStdOutputSize behavior (e.g., creating .playwright/cli.config.json, exercising a command that prints output, and asserting stdout vs file output). Adding an integration spec for this new mode would prevent regressions around thresholding and outputDir handling.
| | Zero threshold | `maxStdOutputSize: 0` | Output to file | ✓ Pass | | ||
| | Large threshold | `maxStdOutputSize: 100000` | Output to stdout | ✓ Pass | | ||
| | stdout mode | `outputMode: "stdout"` | Output to stdout | ✓ Pass | | ||
| | file mode | `outputMode: "file"` | Output to stdout (existing behavior) | ✓ Pass | |
There was a problem hiding this comment.
This report says outputMode: "file" results in "Output to stdout (existing behavior)", but the README schema comment in this PR states that "file" saves all output to files in outputDir. Please align the documentation/test report so users aren't left with conflicting behavior descriptions.
| | file mode | `outputMode: "file"` | Output to stdout (existing behavior) | ✓ Pass | | |
| | file mode | `outputMode: "file"` | Output to file | ✓ Pass | |
| * - "stdout": Output to console immediately | ||
| * - "file": Save all output to files in outputDir | ||
| * - "fileOnLargeOutput": Output to stdout if small, otherwise save to file (controlled by maxStdOutputSize) |
There was a problem hiding this comment.
The docs say fileOnLargeOutput will "Output to stdout if small" / "Output to console immediately", but the current implementation buffers output and only prints it after program() finishes. Please either update the implementation to stream output as intended or clarify in the docs that output may be delayed while the CLI decides whether to write to a file.
| * - "stdout": Output to console immediately | |
| * - "file": Save all output to files in outputDir | |
| * - "fileOnLargeOutput": Output to stdout if small, otherwise save to file (controlled by maxStdOutputSize) | |
| * Output may be buffered before it is written, especially when the CLI needs to decide whether to keep output on stdout or save it to a file. | |
| * - "stdout": Write output to stdout. Depending on buffering, output may be emitted after the session completes rather than immediately. | |
| * - "file": Save all output to files in outputDir | |
| * - "fileOnLargeOutput": Buffer output while determining its size; write to stdout if it stays within maxStdOutputSize, otherwise save it to file |
| } | ||
| } | ||
| } catch {} | ||
| return path.join(os.tmpdir(), 'playwright-cli-output'); |
There was a problem hiding this comment.
For fileOnLargeOutput, getOutputDir() falls back to a fixed path under the OS temp dir (<tmp>/playwright-cli-output). This can accumulate files across runs and differs from whatever default outputDir behavior program() may already implement. Consider using a per-run temp directory (or delegating outputDir defaulting to the underlying CLI config logic) to avoid unbounded growth and keep behavior consistent across output modes.
| return path.join(os.tmpdir(), 'playwright-cli-output'); | |
| return fs.mkdtempSync(path.join(os.tmpdir(), 'playwright-cli-output-')); |
Testing Validation Results for fileOnLargeOutput Feature
Test Summary
outputMode: "fileOnLargeOutput"(no maxStdOutputSize)maxStdOutputSize: 100maxStdOutputSize: 0maxStdOutputSize: 100000outputMode: "stdout"outputMode: "file"maxStdOutputSize: 100outputMode: "fileOnLargeOutput"Manual Tests Performed
Edge Cases Tested
0for maxStdOutputSize → correctly treated as thresholdVerification