diff --git a/.serena/.gitignore b/.serena/.gitignore deleted file mode 100644 index 14d86ad..0000000 --- a/.serena/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/cache diff --git a/.serena/project.yml b/.serena/project.yml deleted file mode 100644 index 9e930cd..0000000 --- a/.serena/project.yml +++ /dev/null @@ -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" diff --git a/changelog.md b/changelog.md index 23d9027..b5750a5 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/readme.hints.md b/readme.hints.md index b96c3ca..877ddb3 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -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. \ No newline at end of file +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. \ No newline at end of file diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index c8eae09..f3b9cc6 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -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' } diff --git a/ts/tstest.classes.tap.parser.ts b/ts/tstest.classes.tap.parser.ts index 58665bd..7eec230 100644 --- a/ts/tstest.classes.tap.parser.ts +++ b/ts/tstest.classes.tap.parser.ts @@ -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(); }); diff --git a/ts/tstest.classes.tap.testresult.ts b/ts/tstest.classes.tap.testresult.ts index c5a2eb9..0103dfe 100644 --- a/ts/tstest.classes.tap.testresult.ts +++ b/ts/tstest.classes.tap.testresult.ts @@ -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; diff --git a/ts/tstest.logging.ts b/ts/tstest.logging.ts index 71a5348..a9cfd13 100644 --- a/ts/tstest.logging.ts +++ b/ts/tstest.logging.ts @@ -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) {