fix(tstest): preserve streaming console output and correctly buffer incomplete TAP lines
This commit is contained in:
1
.serena/.gitignore
vendored
1
.serena/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
/cache
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -446,3 +446,48 @@ The protocol parser was fixed to correctly handle inline timing metadata:
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export class TapParser {
|
||||
private protocolParser: ProtocolParser;
|
||||
private protocolVersion: string | null = null;
|
||||
private startTime: number;
|
||||
private lineBuffer: string = '';
|
||||
|
||||
/**
|
||||
* the constructor for TapParser
|
||||
@@ -71,28 +72,71 @@ export class TapParser {
|
||||
if (Buffer.isBuffer(logChunk)) {
|
||||
logChunk = logChunk.toString();
|
||||
}
|
||||
const logLineArray = logChunk.split('\n');
|
||||
if (logLineArray[logLineArray.length - 1] === '') {
|
||||
logLineArray.pop();
|
||||
}
|
||||
|
||||
// Process each line through the protocol parser
|
||||
for (const logLine of logLineArray) {
|
||||
const messages = this.protocolParser.parseLine(logLine);
|
||||
// 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, logLine);
|
||||
this._handleProtocolMessage(message, segment);
|
||||
}
|
||||
} else {
|
||||
// Not a protocol message, handle as console output
|
||||
if (this.activeTapTestResult) {
|
||||
this.activeTapTestResult.addLogLine(logLine);
|
||||
// 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 = logLine.match(/###SNAPSHOT###(.+)###SNAPSHOT###/);
|
||||
const snapshotMatch = text.match(/###SNAPSHOT###(.+)###SNAPSHOT###/);
|
||||
if (snapshotMatch) {
|
||||
const base64Data = snapshotMatch[1];
|
||||
try {
|
||||
@@ -103,10 +147,24 @@ export class TapParser {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to test result buffer
|
||||
if (this.activeTapTestResult) {
|
||||
if (hasNewline) {
|
||||
this.activeTapTestResult.addLogLine(text);
|
||||
} else {
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -348,7 +348,7 @@ 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;
|
||||
|
||||
@@ -366,6 +366,42 @@ export class TsTestLogger {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (this.options.json) {
|
||||
|
||||
Reference in New Issue
Block a user