fix(tstest): preserve streaming console output and correctly buffer incomplete TAP lines

This commit is contained in:
2026-01-19 19:14:05 +00:00
parent ae59b7adf2
commit 46f0a5a8cf
8 changed files with 202 additions and 109 deletions

1
.serena/.gitignore vendored
View File

@@ -1 +0,0 @@
/cache

View File

@@ -1,68 +0,0 @@
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
# * For C, use cpp
# * For JavaScript, use typescript
# Special requirements:
# * csharp: Requires the presence of a .sln file in the project folder.
language: typescript
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
project_name: "tstest"

View File

@@ -1,5 +1,14 @@
# Changelog
## 2026-01-19 - 3.1.5 - fix(tstest)
preserve streaming console output and correctly buffer incomplete TAP lines
- Reworked TapParser._processLog to buffer incomplete lines and only parse complete TAP protocol lines
- Added TapParser.lineBuffer and _looksLikeTapStart() to detect and buffer starts of TAP messages
- Added TapParser._handleConsoleOutput() to centralize console output handling and snapshot parsing; flushes buffered content on process exit
- Added TapTestResult.addLogLineRaw() to append streaming text without adding newlines
- Added TsTestLogger.testConsoleOutputStreaming() and logToTestFileRaw() to preserve streaming output formatting in both console and logfile
## 2025-12-30 - 3.1.4 - fix(webhelpers)
improve browser test fixture to append element and await custom element upgrade and Lit update completion; add generic return type; update npm packaging release config; remove pnpm onlyBuiltDependencies

View File

@@ -445,4 +445,49 @@ The protocol parser was fixed to correctly handle inline timing metadata:
- Changed condition from `!simpleMatch[1].includes(':')` to check for simple key:value pairs
- Excludes prefixed formats (META:, SKIP:, TODO:, EVENT:) while parsing simple formats like `time:250`
This ensures timing metadata is correctly extracted and displayed in test results.
This ensures timing metadata is correctly extracted and displayed in test results.
## Streaming Console Output (Fixed)
### Problem
When tests use `process.stdout.write()` for streaming output (without newlines), each write was appearing on a separate line. This happened because:
1. Child process stdout data events arrive as separate chunks
2. `TapParser._processLog()` split on `\n` and processed each segment
3. `testConsoleOutput()` used `console.log()` which added a newline to each call
### Solution
The streaming behavior is now preserved by:
1. **Line buffering for TAP parsing**: Only buffer content that looks like TAP protocol messages
2. **True streaming for console output**: Use `process.stdout.write()` instead of `console.log()` for partial lines
3. **Intelligent detection**: `_looksLikeTapStart()` checks if content could be a TAP protocol message
### Implementation Details
**TapParser changes:**
- Added `lineBuffer` property to buffer incomplete TAP protocol lines
- Rewrote `_processLog()` to handle streaming correctly:
- Complete lines (with newline) are processed through protocol parser
- Incomplete lines that look like TAP are buffered
- Incomplete lines that don't look like TAP are streamed immediately
- Added `_looksLikeTapStart()` helper to detect TAP protocol patterns
- Added `_handleConsoleOutput()` to handle console output with proper streaming
- Buffer is flushed on process exit
**TsTestLogger changes:**
- Added `testConsoleOutputStreaming()` method that uses `process.stdout.write()` in verbose mode
- Added `logToTestFileRaw()` for writing to log files without adding newlines
- In non-verbose mode, streaming content is appended to the last buffered entry
**TapTestResult changes:**
- Added `addLogLineRaw()` method that doesn't append newlines
### Usage
Tests can now use streaming output naturally:
```typescript
process.stdout.write("Loading");
process.stdout.write(".");
process.stdout.write(".");
process.stdout.write(".\n");
```
This will correctly display as `Loading...` on a single line in verbose mode.

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@git.zone/tstest',
version: '3.1.4',
version: '3.1.5',
description: 'a test utility to run tests that match test/**/*.ts'
}

View File

@@ -18,11 +18,12 @@ export class TapParser {
receivedTests: number = 0;
activeTapTestResult: TapTestResult;
private logger: TsTestLogger;
private protocolParser: ProtocolParser;
private protocolVersion: string | null = null;
private startTime: number;
private lineBuffer: string = '';
/**
* the constructor for TapParser
@@ -71,42 +72,99 @@ export class TapParser {
if (Buffer.isBuffer(logChunk)) {
logChunk = logChunk.toString();
}
const logLineArray = logChunk.split('\n');
if (logLineArray[logLineArray.length - 1] === '') {
logLineArray.pop();
// Prepend any buffered content from previous incomplete line
const fullChunk = this.lineBuffer + logChunk;
this.lineBuffer = '';
// Split into segments by newline
const segments = fullChunk.split('\n');
const lastIndex = segments.length - 1;
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
const isLastSegment = (i === lastIndex);
const hasNewline = !isLastSegment; // All segments except last had a newline after them
if (hasNewline) {
// Complete line - check if it's a TAP protocol message
const messages = this.protocolParser.parseLine(segment);
if (messages.length > 0) {
// Handle protocol messages
for (const message of messages) {
this._handleProtocolMessage(message, segment);
}
} else {
// Non-protocol complete line - handle as console output
this._handleConsoleOutput(segment, true);
}
} else if (segment) {
// Last segment without newline - could be:
// 1. Partial console output (stream immediately)
// 2. Start of a TAP message (need to buffer for protocol parsing)
// Check if it looks like the start of a TAP protocol message
if (this._looksLikeTapStart(segment)) {
// Buffer it for complete line parsing
this.lineBuffer = segment;
} else {
// Stream immediately as console output (no newline)
this._handleConsoleOutput(segment, false);
}
}
}
}
/**
* Check if text could be the start of a TAP protocol message
*/
private _looksLikeTapStart(text: string): boolean {
return (
text.startsWith('ok ') ||
text.startsWith('not ok ') ||
text.startsWith('1..') ||
text.startsWith('# ') ||
text.startsWith('TAP version ') ||
text.startsWith('⟦TSTEST:') ||
text.startsWith('Bail out!')
);
}
/**
* Handle console output from test, preserving streaming behavior
*/
private _handleConsoleOutput(text: string, hasNewline: boolean) {
// Check for snapshot communication (legacy)
const snapshotMatch = text.match(/###SNAPSHOT###(.+)###SNAPSHOT###/);
if (snapshotMatch) {
const base64Data = snapshotMatch[1];
try {
const snapshotData = JSON.parse(Buffer.from(base64Data, 'base64').toString());
this.handleSnapshot(snapshotData);
} catch (error: any) {
if (this.logger) {
this.logger.testConsoleOutput(`Error parsing snapshot data: ${error.message}`);
}
}
return;
}
// Process each line through the protocol parser
for (const logLine of logLineArray) {
const messages = this.protocolParser.parseLine(logLine);
if (messages.length > 0) {
// Handle protocol messages
for (const message of messages) {
this._handleProtocolMessage(message, logLine);
}
// Add to test result buffer
if (this.activeTapTestResult) {
if (hasNewline) {
this.activeTapTestResult.addLogLine(text);
} else {
// Not a protocol message, handle as console output
if (this.activeTapTestResult) {
this.activeTapTestResult.addLogLine(logLine);
}
// Check for snapshot communication (legacy)
const snapshotMatch = logLine.match(/###SNAPSHOT###(.+)###SNAPSHOT###/);
if (snapshotMatch) {
const base64Data = snapshotMatch[1];
try {
const snapshotData = JSON.parse(Buffer.from(base64Data, 'base64').toString());
this.handleSnapshot(snapshotData);
} catch (error: any) {
if (this.logger) {
this.logger.testConsoleOutput(`Error parsing snapshot data: ${error.message}`);
}
}
} else if (this.logger) {
// This is console output from the test file
this.logger.testConsoleOutput(logLine);
}
this.activeTapTestResult.addLogLineRaw(text);
}
}
// Output to logger with streaming support
if (this.logger) {
if (hasNewline) {
this.logger.testConsoleOutput(text);
} else {
this.logger.testConsoleOutputStreaming(text);
}
}
}
@@ -417,6 +475,11 @@ export class TapParser {
this._processLog(data);
});
childProcessArg.on('exit', async () => {
// Flush any remaining buffered content
if (this.lineBuffer) {
this._handleConsoleOutput(this.lineBuffer, false);
this.lineBuffer = '';
}
await this.evaluateFinalResult();
done.resolve();
});

View File

@@ -10,7 +10,7 @@ export class TapTestResult {
constructor(public id: number) {}
/**
* adds a logLine to the log buffer of the test
* adds a logLine to the log buffer of the test (with newline appended)
* @param logLine
*/
addLogLine(logLine: string) {
@@ -19,6 +19,15 @@ export class TapTestResult {
this.testLogBuffer = Buffer.concat([this.testLogBuffer, logLineBuffer]);
}
/**
* adds raw text to the log buffer without appending newline (for streaming output)
* @param text
*/
addLogLineRaw(text: string) {
const logLineBuffer = Buffer.from(text);
this.testLogBuffer = Buffer.concat([this.testLogBuffer, logLineBuffer]);
}
setTestResult(testOkArg: boolean) {
this.testOk = testOkArg;
this.testSettled = true;

View File

@@ -348,10 +348,10 @@ export class TsTestLogger {
}
}
// Console output from test files (non-TAP output)
// Console output from test files (non-TAP output) - complete lines
testConsoleOutput(message: string) {
if (this.options.json) return;
// In verbose mode, show console output immediately
if (this.options.verbose) {
this.log(this.format(` ${message}`, 'dim'));
@@ -359,12 +359,48 @@ export class TsTestLogger {
// In non-verbose mode, buffer the logs
this.currentTestLogs.push(message);
}
// Always log to test file if --logfile is specified
if (this.currentTestLogFile) {
this.logToTestFile(` ${message}`);
}
}
// Streaming console output (preserves original formatting, no newline added)
testConsoleOutputStreaming(message: string) {
if (this.options.json) return;
const prefix = ' ';
if (this.options.verbose) {
// Use process.stdout.write to preserve streaming without adding newlines
process.stdout.write(this.format(prefix + message, 'dim'));
} else {
// Buffer without trailing newline - append to last entry if exists and incomplete
if (this.currentTestLogs.length > 0) {
// Append to the last buffered entry (for streaming segments)
this.currentTestLogs[this.currentTestLogs.length - 1] += message;
} else {
this.currentTestLogs.push(message);
}
}
// Log to test file without adding newline
if (this.currentTestLogFile) {
this.logToTestFileRaw(prefix + message);
}
}
private logToTestFileRaw(message: string) {
try {
// Remove ANSI color codes for file logging
const cleanMessage = message.replace(/\u001b\[[0-9;]*m/g, '');
// Append to test log file without adding newline
fs.appendFileSync(this.currentTestLogFile, cleanMessage);
} catch (error) {
// Silently fail to avoid disrupting the test run
}
}
// Skipped test file
testFileSkipped(filename: string, index: number, total: number, reason: string) {