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
|
# 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)
|
## 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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- 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`
|
- 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.
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@git.zone/tstest',
|
name: '@git.zone/tstest',
|
||||||
version: '3.1.4',
|
version: '3.1.5',
|
||||||
description: 'a test utility to run tests that match test/**/*.ts'
|
description: 'a test utility to run tests that match test/**/*.ts'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ export class TapParser {
|
|||||||
receivedTests: number = 0;
|
receivedTests: number = 0;
|
||||||
|
|
||||||
activeTapTestResult: TapTestResult;
|
activeTapTestResult: TapTestResult;
|
||||||
|
|
||||||
private logger: TsTestLogger;
|
private logger: TsTestLogger;
|
||||||
private protocolParser: ProtocolParser;
|
private protocolParser: ProtocolParser;
|
||||||
private protocolVersion: string | null = null;
|
private protocolVersion: string | null = null;
|
||||||
private startTime: number;
|
private startTime: number;
|
||||||
|
private lineBuffer: string = '';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* the constructor for TapParser
|
* the constructor for TapParser
|
||||||
@@ -71,42 +72,99 @@ export class TapParser {
|
|||||||
if (Buffer.isBuffer(logChunk)) {
|
if (Buffer.isBuffer(logChunk)) {
|
||||||
logChunk = logChunk.toString();
|
logChunk = logChunk.toString();
|
||||||
}
|
}
|
||||||
const logLineArray = logChunk.split('\n');
|
|
||||||
if (logLineArray[logLineArray.length - 1] === '') {
|
// Prepend any buffered content from previous incomplete line
|
||||||
logLineArray.pop();
|
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
|
// Add to test result buffer
|
||||||
for (const logLine of logLineArray) {
|
if (this.activeTapTestResult) {
|
||||||
const messages = this.protocolParser.parseLine(logLine);
|
if (hasNewline) {
|
||||||
|
this.activeTapTestResult.addLogLine(text);
|
||||||
if (messages.length > 0) {
|
|
||||||
// Handle protocol messages
|
|
||||||
for (const message of messages) {
|
|
||||||
this._handleProtocolMessage(message, logLine);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Not a protocol message, handle as console output
|
this.activeTapTestResult.addLogLineRaw(text);
|
||||||
if (this.activeTapTestResult) {
|
}
|
||||||
this.activeTapTestResult.addLogLine(logLine);
|
}
|
||||||
}
|
|
||||||
|
// Output to logger with streaming support
|
||||||
// Check for snapshot communication (legacy)
|
if (this.logger) {
|
||||||
const snapshotMatch = logLine.match(/###SNAPSHOT###(.+)###SNAPSHOT###/);
|
if (hasNewline) {
|
||||||
if (snapshotMatch) {
|
this.logger.testConsoleOutput(text);
|
||||||
const base64Data = snapshotMatch[1];
|
} else {
|
||||||
try {
|
this.logger.testConsoleOutputStreaming(text);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -417,6 +475,11 @@ export class TapParser {
|
|||||||
this._processLog(data);
|
this._processLog(data);
|
||||||
});
|
});
|
||||||
childProcessArg.on('exit', async () => {
|
childProcessArg.on('exit', async () => {
|
||||||
|
// Flush any remaining buffered content
|
||||||
|
if (this.lineBuffer) {
|
||||||
|
this._handleConsoleOutput(this.lineBuffer, false);
|
||||||
|
this.lineBuffer = '';
|
||||||
|
}
|
||||||
await this.evaluateFinalResult();
|
await this.evaluateFinalResult();
|
||||||
done.resolve();
|
done.resolve();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export class TapTestResult {
|
|||||||
constructor(public id: number) {}
|
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
|
* @param logLine
|
||||||
*/
|
*/
|
||||||
addLogLine(logLine: string) {
|
addLogLine(logLine: string) {
|
||||||
@@ -19,6 +19,15 @@ export class TapTestResult {
|
|||||||
this.testLogBuffer = Buffer.concat([this.testLogBuffer, logLineBuffer]);
|
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) {
|
setTestResult(testOkArg: boolean) {
|
||||||
this.testOk = testOkArg;
|
this.testOk = testOkArg;
|
||||||
this.testSettled = true;
|
this.testSettled = true;
|
||||||
|
|||||||
@@ -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) {
|
testConsoleOutput(message: string) {
|
||||||
if (this.options.json) return;
|
if (this.options.json) return;
|
||||||
|
|
||||||
// In verbose mode, show console output immediately
|
// In verbose mode, show console output immediately
|
||||||
if (this.options.verbose) {
|
if (this.options.verbose) {
|
||||||
this.log(this.format(` ${message}`, 'dim'));
|
this.log(this.format(` ${message}`, 'dim'));
|
||||||
@@ -359,12 +359,48 @@ export class TsTestLogger {
|
|||||||
// In non-verbose mode, buffer the logs
|
// In non-verbose mode, buffer the logs
|
||||||
this.currentTestLogs.push(message);
|
this.currentTestLogs.push(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always log to test file if --logfile is specified
|
// Always log to test file if --logfile is specified
|
||||||
if (this.currentTestLogFile) {
|
if (this.currentTestLogFile) {
|
||||||
this.logToTestFile(` ${message}`);
|
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
|
// Skipped test file
|
||||||
testFileSkipped(filename: string, index: number, total: number, reason: string) {
|
testFileSkipped(filename: string, index: number, total: number, reason: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user