From 544dc492db63c9f5a14b3b98655d09fe882241ce Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Thu, 30 Apr 2026 17:00:10 +0200 Subject: [PATCH 01/13] feature: filter noisy serial ports --- src/serialPortManager.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/serialPortManager.ts b/src/serialPortManager.ts index fb20422..74ea73e 100644 --- a/src/serialPortManager.ts +++ b/src/serialPortManager.ts @@ -51,10 +51,25 @@ export class SerialPortManager extends vscode.Disposable { return this._isConnected; } + private filterPorts( + ports: T[] + ): T[] { + if (process.platform === 'darwin') { + return ports.filter((port) => !/\.(Bluetooth|debug)/i.test(port.path)); + } else if (process.platform === 'linux') { + return ports.filter((port) => !/\/(ttyS\d+|rfcomm)/.test(port.path)); + } else if (process.platform === 'win32') { + return ports.filter( + (port) => !/bluetooth/i.test(port.manufacturer || '') + ); + } + return ports; + } + async listPorts(): Promise { try { const ports = await SerialPort.list(); - return ports.map((p) => ({ + const mappedPorts = ports.map((p) => ({ path: p.path, manufacturer: p.manufacturer, serialNumber: p.serialNumber, @@ -62,6 +77,7 @@ export class SerialPortManager extends vscode.Disposable { productId: p.productId, friendlyName: (p as unknown as Record).friendlyName as string | undefined, })); + return this.filterPorts(mappedPorts); } catch (err) { vscode.window.showErrorMessage( `Failed to list serial ports: ${err instanceof Error ? err.message : err}` From 418fe742446445cdd6ab9751e58ff951c12b32d4 Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Thu, 30 Apr 2026 17:08:49 +0200 Subject: [PATCH 02/13] update changelog --- CHANGELOG.md | 8 ++++++++ package-lock.json | 4 ++-- package.json | 5 +++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88a6bc9..4fa067f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.24.1] - 2026-04-30 + +### Changed +- **Port filtering** — integrated platform-specific port filters to exclude non-serial devices: + - **macOS**: Filters out Bluetooth and debug ports (`.Bluetooth`, `.debug` suffixes). + - **Linux**: Filters out system serial ports (`ttyS*`) and Bluetooth RFCOMM ports (`rfcomm`). + - **Windows**: Filters out Bluetooth devices by manufacturer name. + ## [0.24.0] - 2026-04-30 ### Added diff --git a/package-lock.json b/package-lock.json index 5751dc0..44e9e52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "esp-decoder", - "version": "0.24.0", + "version": "0.24.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "esp-decoder", - "version": "0.24.0", + "version": "0.24.1", "license": "GPL-3.0", "dependencies": { "serialport": "^13.0.0" diff --git a/package.json b/package.json index e1258ad..d2f4569 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "esp-decoder", "displayName": "ESP Crash Decoder", "description": "Decode ESP32 / ESP8266 crash dumps from serial port", - "version": "0.24.0", + "version": "0.24.1", "publisher": "Jason2866", "license": "GPL-3.0", "icon": "assets/icon_large.png", @@ -54,7 +54,8 @@ { "type": "webview", "id": "esp-decoder.monitorView", - "name": "ESP Crash Monitor" + "name": "ESP Crash Monitor", + "icon": "assets/icon.svg" } ] }, From 58725072bad3952c71e6e4ffac96c07292f2f246 Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Thu, 30 Apr 2026 17:25:49 +0200 Subject: [PATCH 03/13] add tests for port filter --- CHANGELOG.md | 2 +- src/serialPortManager.ts | 2 +- src/test/serialPortManager.test.ts | 320 +++++++++++++++++++++++++++++ 3 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 src/test/serialPortManager.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fa067f..c5a01ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Changed - **Port filtering** — integrated platform-specific port filters to exclude non-serial devices: - - **macOS**: Filters out Bluetooth and debug ports (`.Bluetooth`, `.debug` suffixes). + - **macOS**: Filters out Bluetooth and debug ports (`.Bluetooth`, `.debug` matches Bluetooth/debug-style paths). - **Linux**: Filters out system serial ports (`ttyS*`) and Bluetooth RFCOMM ports (`rfcomm`). - **Windows**: Filters out Bluetooth devices by manufacturer name. diff --git a/src/serialPortManager.ts b/src/serialPortManager.ts index 74ea73e..0c55f4e 100644 --- a/src/serialPortManager.ts +++ b/src/serialPortManager.ts @@ -51,7 +51,7 @@ export class SerialPortManager extends vscode.Disposable { return this._isConnected; } - private filterPorts( + public filterPorts( ports: T[] ): T[] { if (process.platform === 'darwin') { diff --git a/src/test/serialPortManager.test.ts b/src/test/serialPortManager.test.ts new file mode 100644 index 0000000..0d72b6f --- /dev/null +++ b/src/test/serialPortManager.test.ts @@ -0,0 +1,320 @@ +/** + * Unit tests for SerialPortManager.filterPorts regex filtering. + * + * Covers platform-specific filtering for darwin (macOS), linux, and win32 + * to prevent regressions in Bluetooth/internal port exclusion. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { SerialPortManager } from '../serialPortManager.js'; + +// Mock vscode before importing SerialPortManager +vi.mock('vscode', () => { + class EventEmitter { + private _listeners: ((e: T) => void)[] = []; + + get event() { + return (listener: (e: T) => void) => { + this._listeners.push(listener); + return { + dispose: () => { + this._listeners = this._listeners.filter((l) => l !== listener); + }, + }; + }; + } + + fire(e: T) { + this._listeners.forEach((l) => l(e)); + } + + dispose() { + this._listeners = []; + } + } + + return { + EventEmitter, + window: { + createOutputChannel: () => ({ + appendLine: () => {}, + dispose: () => {}, + }), + }, + workspace: { + getConfiguration: () => ({ + get: () => 115200, + }), + }, + Disposable: class { + dispose() {} + }, + }; +}); + +interface PortEntry { + path: string; + manufacturer?: string; +} + +interface FilterPortsTestCase { + name: string; + platform: 'darwin' | 'linux' | 'win32'; + input: PortEntry[]; + expectedKeptPaths: string[]; + expectedFilteredPaths: string[]; +} + +describe('SerialPortManager.filterPorts', () => { + let manager: SerialPortManager; + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); + + beforeEach(() => { + manager = new SerialPortManager(); + }); + + afterEach(() => { + // Restore original platform + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform); + } else { + // If there was no original descriptor, delete the mock + // @ts-expect-error: platform is read-only + delete process.platform; + } + }); + + function setPlatform(platform: 'darwin' | 'linux' | 'win32') { + Object.defineProperty(process, 'platform', { + value: platform, + writable: true, + enumerable: true, + configurable: true, + }); + } + + const testCases: FilterPortsTestCase[] = [ + // Darwin (macOS) tests + { + name: 'filterPorts filters out .Bluetooth paths on darwin', + platform: 'darwin', + input: [ + { path: '/dev/tty.usbserial-1234' }, + { path: '/dev/tty.Bluetooth-Incoming-Port' }, + { path: '/dev/tty.usbmodem-5678' }, + ], + expectedKeptPaths: ['/dev/tty.usbserial-1234', '/dev/tty.usbmodem-5678'], + expectedFilteredPaths: ['/dev/tty.Bluetooth-Incoming-Port'], + }, + { + name: 'filterPorts filters out .debug paths on darwin (case insensitive)', + platform: 'darwin', + input: [ + { path: '/dev/tty.debug-xyz' }, + { path: '/dev/tty.DEBUG-ABC' }, + { path: '/dev/tty.usbserial-1234' }, + ], + expectedKeptPaths: ['/dev/tty.usbserial-1234'], + expectedFilteredPaths: ['/dev/tty.debug-xyz', '/dev/tty.DEBUG-ABC'], + }, + { + name: 'filterPorts handles mixed Bluetooth and debug on darwin', + platform: 'darwin', + input: [ + { path: '/dev/tty.Bluetooth-Keyboard' }, + { path: '/dev/tty.debug-console' }, + { path: '/dev/tty.usbserial-FTDI123' }, + { path: '/dev/cu.usbserial-FTDI123' }, + ], + expectedKeptPaths: ['/dev/tty.usbserial-FTDI123', '/dev/cu.usbserial-FTDI123'], + expectedFilteredPaths: ['/dev/tty.Bluetooth-Keyboard', '/dev/tty.debug-console'], + }, + + // Linux tests + { + name: 'filterPorts filters out /ttyS0 on linux', + platform: 'linux', + input: [ + { path: '/dev/ttyUSB0' }, + { path: '/dev/ttyS0' }, + { path: '/dev/ttyACM0' }, + ], + expectedKeptPaths: ['/dev/ttyUSB0', '/dev/ttyACM0'], + expectedFilteredPaths: ['/dev/ttyS0'], + }, + { + name: 'filterPorts filters out all /ttyS* ports on linux', + platform: 'linux', + input: [ + { path: '/dev/ttyS0' }, + { path: '/dev/ttyS1' }, + { path: '/dev/ttyS99' }, + { path: '/dev/ttyUSB0' }, + ], + expectedKeptPaths: ['/dev/ttyUSB0'], + expectedFilteredPaths: ['/dev/ttyS0', '/dev/ttyS1', '/dev/ttyS99'], + }, + { + name: 'filterPorts filters out /rfcomm ports on linux', + platform: 'linux', + input: [ + { path: '/dev/rfcomm0' }, + { path: '/dev/rfcomm1' }, + { path: '/dev/rfcomm99' }, + { path: '/dev/ttyUSB0' }, + ], + expectedKeptPaths: ['/dev/ttyUSB0'], + expectedFilteredPaths: ['/dev/rfcomm0', '/dev/rfcomm1', '/dev/rfcomm99'], + }, + { + name: 'filterPorts handles mixed ttyS and rfcomm on linux', + platform: 'linux', + input: [ + { path: '/dev/rfcomm0' }, + { path: '/dev/ttyS0' }, + { path: '/dev/ttyS5' }, + { path: '/dev/ttyUSB0' }, + { path: '/dev/ttyACM0' }, + ], + expectedKeptPaths: ['/dev/ttyUSB0', '/dev/ttyACM0'], + expectedFilteredPaths: ['/dev/rfcomm0', '/dev/ttyS0', '/dev/ttyS5'], + }, + + // Win32 tests + { + name: 'filterPorts filters out Bluetooth manufacturer on win32', + platform: 'win32', + input: [ + { path: 'COM3', manufacturer: 'Silicon Labs' }, + { path: 'COM4', manufacturer: 'Microsoft Bluetooth' }, + { path: 'COM5', manufacturer: 'FTDI' }, + ], + expectedKeptPaths: ['COM3', 'COM5'], + expectedFilteredPaths: ['COM4'], + }, + { + name: 'filterPorts filters out bluetooth (lowercase) manufacturer on win32', + platform: 'win32', + input: [ + { path: 'COM1', manufacturer: 'Generic bluetooth adapter' }, + { path: 'COM2', manufacturer: 'Bluetooth Serial' }, + { path: 'COM3', manufacturer: 'Prolific' }, + ], + expectedKeptPaths: ['COM3'], + expectedFilteredPaths: ['COM1', 'COM2'], + }, + { + name: 'filterPorts handles empty/undefined manufacturer on win32', + platform: 'win32', + input: [ + { path: 'COM1', manufacturer: undefined }, + { path: 'COM2', manufacturer: '' }, + { path: 'COM3', manufacturer: 'Unknown' }, + { path: 'COM4', manufacturer: 'Bluetooth' }, + ], + expectedKeptPaths: ['COM1', 'COM2', 'COM3'], + expectedFilteredPaths: ['COM4'], + }, + { + name: 'filterPorts keeps all ports on win32 when no Bluetooth', + platform: 'win32', + input: [ + { path: 'COM1', manufacturer: 'Silicon Labs' }, + { path: 'COM2', manufacturer: 'FTDI' }, + { path: 'COM3', manufacturer: 'Prolific' }, + ], + expectedKeptPaths: ['COM1', 'COM2', 'COM3'], + expectedFilteredPaths: [], + }, + + // Edge cases - empty arrays + { + name: 'filterPorts returns empty array when given empty array (darwin)', + platform: 'darwin', + input: [], + expectedKeptPaths: [], + expectedFilteredPaths: [], + }, + { + name: 'filterPorts returns empty array when given empty array (linux)', + platform: 'linux', + input: [], + expectedKeptPaths: [], + expectedFilteredPaths: [], + }, + { + name: 'filterPorts returns empty array when given empty array (win32)', + platform: 'win32', + input: [], + expectedKeptPaths: [], + expectedFilteredPaths: [], + }, + + // Edge cases - all filtered + { + name: 'filterPorts returns empty array when all ports filtered (darwin)', + platform: 'darwin', + input: [ + { path: '/dev/tty.Bluetooth-1' }, + { path: '/dev/tty.debug-2' }, + ], + expectedKeptPaths: [], + expectedFilteredPaths: ['/dev/tty.Bluetooth-1', '/dev/tty.debug-2'], + }, + { + name: 'filterPorts returns empty array when all ports filtered (linux)', + platform: 'linux', + input: [ + { path: '/dev/ttyS0' }, + { path: '/dev/rfcomm0' }, + ], + expectedKeptPaths: [], + expectedFilteredPaths: ['/dev/ttyS0', '/dev/rfcomm0'], + }, + { + name: 'filterPorts returns empty array when all ports filtered (win32)', + platform: 'win32', + input: [ + { path: 'COM1', manufacturer: 'Bluetooth 1' }, + { path: 'COM2', manufacturer: 'Bluetooth 2' }, + ], + expectedKeptPaths: [], + expectedFilteredPaths: ['COM1', 'COM2'], + }, + ]; + + testCases.forEach((testCase) => { + it(testCase.name, () => { + setPlatform(testCase.platform); + const result = manager.filterPorts(testCase.input); + + const resultPaths = result.map((p) => p.path); + + expect(resultPaths).toEqual(testCase.expectedKeptPaths); + + // Verify filtered paths are NOT in the result + for (const filteredPath of testCase.expectedFilteredPaths) { + expect(resultPaths).not.toContain(filteredPath); + } + }); + }); + + describe('filterPorts handles unknown platform gracefully', () => { + it('returns all ports unfiltered for unknown platform', () => { + Object.defineProperty(process, 'platform', { + value: 'freebsd', + writable: true, + enumerable: true, + configurable: true, + }); + + const input: PortEntry[] = [ + { path: '/dev/cuaU0' }, + { path: '/dev/cuaU1' }, + ]; + + const result = manager.filterPorts(input); + expect(result).toEqual(input); + }); + }); +}); From fd4d0d949eafddd03c59fc65286ee371abdbdb33 Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Thu, 30 Apr 2026 17:40:56 +0200 Subject: [PATCH 04/13] tests for PR 42 --- src/test/webviewPanel.test.ts | 373 ++++++++++++++++++++++++++++++++++ 1 file changed, 373 insertions(+) create mode 100644 src/test/webviewPanel.test.ts diff --git a/src/test/webviewPanel.test.ts b/src/test/webviewPanel.test.ts new file mode 100644 index 0000000..6142864 --- /dev/null +++ b/src/test/webviewPanel.test.ts @@ -0,0 +1,373 @@ +/** + * Unit tests for EspDecoderWebviewPanel. + * + * Tests for PR #42 changes: + * - File path resolution (resolveSourcePath) + * - File opening with line and column support (openFile message handler) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as path from 'path'; +import * as fs from 'fs'; +import { fileURLToPath } from 'url'; + +// Mock vscode before importing webviewPanel +vi.mock('vscode', () => { + class EventEmitter { + private _listeners: ((e: T) => void)[] = []; + + get event() { + return (listener: (e: T) => void) => { + this._listeners.push(listener); + return { + dispose: () => { + this._listeners = this._listeners.filter((l) => l !== listener); + }, + }; + }; + } + + fire(e: T) { + this._listeners.forEach((l) => l(e)); + } + + dispose() { + this._listeners = []; + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const workspaceFolders: any = []; + + return { + EventEmitter, + Uri: { + file: (p: string) => ({ fsPath: p }), + parse: (p: string) => ({ fsPath: p }), + }, + Range: class { + constructor( + public startLine: number, + public startChar: number, + public endLine: number, + public endChar: number + ) {} + get start() { + return { line: this.startLine, character: this.startChar }; + } + get end() { + return { line: this.endLine, character: this.endChar }; + } + }, + workspace: { + get workspaceFolders() { + return workspaceFolders; + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + set workspaceFolders(value: any) { + workspaceFolders.length = 0; + workspaceFolders.push(...value); + }, + openTextDocument: vi.fn(), + findFiles: vi.fn(), + }, + window: { + createOutputChannel: () => ({ + appendLine: () => {}, + dispose: () => {}, + }), + showTextDocument: vi.fn(), + showErrorMessage: vi.fn(), + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + }, + Disposable: class { + dispose() {} + }, + ConfigurationTarget: { + Global: 1, + Workspace: 2, + }, + }; +}); + +import { EspDecoderWebviewPanel } from '../webviewPanel.js'; +import { SerialPortManager } from '../serialPortManager.js'; + +// Mock SerialPortManager +vi.mock('../serialPortManager.js', () => { + return { + SerialPortManager: class { + onData = vi.fn(() => ({ dispose: vi.fn() })); + onError = vi.fn(() => ({ dispose: vi.fn() })); + onConnectionChange = vi.fn(() => ({ dispose: vi.fn() })); + isConnected = false; + selectedPath = undefined; + baudRate = 115200; + constructor() {} + }, + }; +}); + +const vscode = await import('vscode'); + +describe('EspDecoderWebviewPanel – PR #42 file opening', () => { + let panel: EspDecoderWebviewPanel; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockOpenTextDocument: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockShowTextDocument: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockShowErrorMessage: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockFindFiles: any; + + beforeEach(() => { + // Reset mocks + mockOpenTextDocument = vi.mocked(vscode.workspace.openTextDocument); + mockShowTextDocument = vi.mocked(vscode.window.showTextDocument); + mockShowErrorMessage = vi.mocked(vscode.window.showErrorMessage); + mockFindFiles = vi.mocked(vscode.workspace.findFiles); + + mockOpenTextDocument.mockResolvedValue({ + uri: { fsPath: '/test/file.cpp' }, + }); + mockShowTextDocument.mockResolvedValue(undefined); + mockShowErrorMessage.mockResolvedValue(undefined); + mockFindFiles.mockResolvedValue([]); + + // Create panel instance + const extensionUri = vscode.Uri.file('/test/extension'); + const serialManager = new SerialPortManager(); + panel = new EspDecoderWebviewPanel(extensionUri, serialManager); + }); + + afterEach(() => { + vi.clearAllMocks(); + // Reset workspace folders to empty array + Object.defineProperty(vscode.workspace, 'workspaceFolders', { + get: () => [], + configurable: true, + }); + }); + + describe('resolveSourcePath', () => { + it('returns absolute path as-is when file exists', async () => { + const testDir = path.dirname(fileURLToPath(import.meta.url)); + const existingFile = path.join(testDir, 'crashDecoder.test.ts'); + + // Access the private method via reflection + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const resolveSourcePath = (panel as any).resolveSourcePath.bind(panel); + const result = await resolveSourcePath(existingFile); + + expect(result).toBe(existingFile.replace(/\\/g, '/')); + }); + + it('normalises backslashes to forward slashes', async () => { + const testDir = path.dirname(fileURLToPath(import.meta.url)); + const existingFile = path.join(testDir, 'crashDecoder.test.ts').replace(/\//g, '\\'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const resolveSourcePath = (panel as any).resolveSourcePath.bind(panel); + const result = await resolveSourcePath(existingFile); + + expect(result).toContain('/'); + expect(result).not.toContain('\\'); + }); + + it('returns original path when absolute file does not exist', async () => { + const nonExistent = '/nonexistent/path/file.cpp'; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const resolveSourcePath = (panel as any).resolveSourcePath.bind(panel); + const result = await resolveSourcePath(nonExistent); + + expect(result).toBe(nonExistent); + }); + + it('resolves relative path against workspace folder when file exists', async () => { + const workspacePath = '/workspace'; + const relativePath = 'src/main.cpp'; + const fullPath = '/workspace/src/main.cpp'; + + // Mock workspace folders + Object.defineProperty(vscode.workspace, 'workspaceFolders', { + get: () => [ + { uri: vscode.Uri.file(workspacePath), name: 'workspace', index: 0 }, + ], + configurable: true, + }); + + // Mock file exists check + const originalAccess = fs.promises.access; + fs.promises.access = vi.fn().mockImplementation((p) => { + if (p === fullPath) return Promise.resolve(); + return Promise.reject(new Error('ENOENT')); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const resolveSourcePath = (panel as any).resolveSourcePath.bind(panel); + const result = await resolveSourcePath(relativePath); + + expect(result).toBe(fullPath); + + // Restore + fs.promises.access = originalAccess; + }); + + it('searches workspace by basename when relative resolution fails', async () => { + const workspacePath = '/workspace'; + const basename = 'main.cpp'; + const foundPath = '/workspace/src/main.cpp'; + + // Mock workspace folders + Object.defineProperty(vscode.workspace, 'workspaceFolders', { + get: () => [ + { uri: vscode.Uri.file(workspacePath), name: 'workspace', index: 0 }, + ], + configurable: true, + }); + + // Mock findFiles to return a match + mockFindFiles.mockResolvedValue([vscode.Uri.file(foundPath)]); + + // Mock file access to fail for relative path + const originalAccess = fs.promises.access; + fs.promises.access = vi.fn().mockRejectedValue(new Error('ENOENT')); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const resolveSourcePath = (panel as any).resolveSourcePath.bind(panel); + const result = await resolveSourcePath('src/deep/nested/main.cpp'); + + expect(result).toBe(foundPath); + expect(mockFindFiles).toHaveBeenCalledWith(`**/${basename}`, '**/node_modules/**', 50); + + // Restore + fs.promises.access = originalAccess; + mockFindFiles.mockResolvedValue([]); + }); + + it('prefers exact suffix match over first match in workspace search', async () => { + const workspacePath = '/workspace'; + const inputPath = 'src/main.cpp'; + const exactMatch = '/workspace/src/main.cpp'; + const otherMatch = '/workspace/other/src/main.cpp'; + + // Mock workspace folders + Object.defineProperty(vscode.workspace, 'workspaceFolders', { + get: () => [ + { uri: vscode.Uri.file(workspacePath), name: 'workspace', index: 0 }, + ], + configurable: true, + }); + + // Mock findFiles to return multiple matches (exact match first to test logic) + mockFindFiles.mockResolvedValue([ + vscode.Uri.file(exactMatch), + vscode.Uri.file(otherMatch), + ]); + + // Mock file access to fail for relative path + const originalAccess = fs.promises.access; + fs.promises.access = vi.fn().mockRejectedValue(new Error('ENOENT')); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const resolveSourcePath = (panel as any).resolveSourcePath.bind(panel); + const result = await resolveSourcePath(inputPath); + + expect(result).toBe(exactMatch); + + // Restore + fs.promises.access = originalAccess; + mockFindFiles.mockResolvedValue([]); + }); + + it('returns original input when no workspace folders exist', async () => { + const inputPath = 'src/main.cpp'; + + // Ensure no workspace folders + Object.defineProperty(vscode.workspace, 'workspaceFolders', { + get: () => [], + configurable: true, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const resolveSourcePath = (panel as any).resolveSourcePath.bind(panel); + const result = await resolveSourcePath(inputPath); + + expect(result).toBe(inputPath); + }); + + it('returns original input when workspace search finds no files', async () => { + const workspacePath = '/workspace'; + const inputPath = 'src/main.cpp'; + + // Mock workspace folders + Object.defineProperty(vscode.workspace, 'workspaceFolders', { + get: () => [ + { uri: vscode.Uri.file(workspacePath), name: 'workspace', index: 0 }, + ], + configurable: true, + }); + + // Mock findFiles to return empty + mockFindFiles.mockResolvedValue([]); + + // Mock file access to fail + const originalAccess = fs.promises.access; + fs.promises.access = vi.fn().mockRejectedValue(new Error('ENOENT')); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const resolveSourcePath = (panel as any).resolveSourcePath.bind(panel); + const result = await resolveSourcePath(inputPath); + + expect(result).toBe(inputPath); + + // Restore + fs.promises.access = originalAccess; + }); + }); + + describe('openFile message handler', () => { + it('shows error message when file cannot be opened', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleMessage = (panel as any).handleMessage.bind(panel); + mockOpenTextDocument.mockRejectedValue(new Error('File not found')); + + await handleMessage({ + type: 'openFile', + file: '/nonexistent/file.cpp', + line: '10', + }); + + expect(mockShowErrorMessage).toHaveBeenCalledWith( + expect.stringContaining('Cannot open file') + ); + }); + + it('does nothing when file is missing from message', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleMessage = (panel as any).handleMessage.bind(panel); + await handleMessage({ + type: 'openFile', + line: '10', + }); + + expect(mockOpenTextDocument).not.toHaveBeenCalled(); + expect(mockShowTextDocument).not.toHaveBeenCalled(); + }); + + it('does nothing when line is missing from message', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleMessage = (panel as any).handleMessage.bind(panel); + await handleMessage({ + type: 'openFile', + file: '/some/file.cpp', + }); + + expect(mockOpenTextDocument).not.toHaveBeenCalled(); + expect(mockShowTextDocument).not.toHaveBeenCalled(); + }); + }); +}); From cf26a0375a70539794950903c7ac7c6aaf2b345e Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Thu, 30 Apr 2026 18:03:33 +0200 Subject: [PATCH 05/13] test: ANSI --- src/test/ansiColor.test.ts | 1183 ++++++++++++++++++++++++++++++++++++ 1 file changed, 1183 insertions(+) create mode 100644 src/test/ansiColor.test.ts diff --git a/src/test/ansiColor.test.ts b/src/test/ansiColor.test.ts new file mode 100644 index 0000000..788160d --- /dev/null +++ b/src/test/ansiColor.test.ts @@ -0,0 +1,1183 @@ +/** + * Unit tests for ANSI color support. + * + * Tests all ANSI SGR (Select Graphic Rendition) features used in the serial terminal: + * - Text styles (bold, dim, italic, underline, strikethrough, blink, fastBlink, hidden, reverse) + * - Foreground colors (standard 8, bright 8, 256-color palette, truecolor RGB) + * - Background colors (standard 8, bright 8, 256-color palette, truecolor RGB) + * - Reset codes (partial and full reset) + * - ANSI state serialization/deserialization + * - Multi-code sequences + * - Edge cases (incomplete sequences, invalid codes) + */ + +import { describe, it, expect, beforeEach } from 'vitest'; + +// ESC character used in ANSI escape sequences +const ESC = '\x1b'; + +// ANSI color state interface matching the implementation +interface AnsiState { + bold: boolean; + italic: boolean; + underline: boolean; + strikethrough: boolean; + blink: boolean; + fastBlink: boolean; + hidden: boolean; + dim: boolean; + reverse: boolean; + fg: string | null; + bg: string | null; + fgRgb: string | null; + bgRgb: string | null; +} + +// Standard 256-color palette (indices 0-255) - matches webviewPanel.ts +const ANSI_256: string[] = (() => { + const t: string[] = []; + // 0-7: standard colors + t[0] = 'rgb(0,0,0)'; + t[1] = 'rgb(128,0,0)'; + t[2] = 'rgb(0,128,0)'; + t[3] = 'rgb(128,128,0)'; + t[4] = 'rgb(0,0,128)'; + t[5] = 'rgb(128,0,128)'; + t[6] = 'rgb(0,128,128)'; + t[7] = 'rgb(192,192,192)'; + // 8-15: bright colors + t[8] = 'rgb(128,128,128)'; + t[9] = 'rgb(255,0,0)'; + t[10] = 'rgb(0,255,0)'; + t[11] = 'rgb(255,255,0)'; + t[12] = 'rgb(99,153,255)'; + t[13] = 'rgb(255,0,255)'; + t[14] = 'rgb(0,255,255)'; + t[15] = 'rgb(255,255,255)'; + // 16-231: 6x6x6 color cube + for (let i = 0; i < 216; i++) { + const r = Math.floor(i / 36); + const g = Math.floor((i % 36) / 6); + const b = i % 6; + t[16 + i] = + 'rgb(' + + (r ? r * 40 + 55 : 0) + + ',' + + (g ? g * 40 + 55 : 0) + + ',' + + (b ? b * 40 + 55 : 0) + + ')'; + } + // 232-255: grayscale ramp + for (let i = 0; i < 24; i++) { + const v = i * 10 + 8; + t[232 + i] = 'rgb(' + v + ',' + v + ',' + v + ')'; + } + return t; +})(); + +// Reset state to default values +function resetAnsiState(state: AnsiState): void { + state.bold = false; + state.dim = false; + state.italic = false; + state.underline = false; + state.strikethrough = false; + state.blink = false; + state.fastBlink = false; + state.hidden = false; + state.reverse = false; + state.fg = null; + state.bg = null; + state.fgRgb = null; + state.bgRgb = null; +} + +// Serialize current state back to SGR escape sequence +function ansiStateToSgr(state: AnsiState): string { + const codes: number[] = []; + if (state.bold) { codes.push(1); } + if (state.dim) { codes.push(2); } + if (state.italic) { codes.push(3); } + if (state.underline) { codes.push(4); } + if (state.blink) { codes.push(5); } + if (state.fastBlink) { codes.push(6); } + if (state.reverse) { codes.push(7); } + if (state.hidden) { codes.push(8); } + if (state.strikethrough) { codes.push(9); } + + const fgMap: Record = { + black: 30, + red: 31, + green: 32, + yellow: 33, + blue: 34, + magenta: 35, + cyan: 36, + white: 37, + }; + const bgMap: Record = { + black: 40, + red: 41, + green: 42, + yellow: 43, + blue: 44, + magenta: 45, + cyan: 46, + white: 47, + }; + + if (state.fgRgb) { + const mfg = /rgb\((\d+),(\d+),(\d+)\)/.exec(state.fgRgb); + if (mfg) { + codes.push(38, 2, +mfg[1], +mfg[2], +mfg[3]); + } + } else if (state.fg && fgMap[state.fg] !== undefined) { + codes.push(fgMap[state.fg]); + } + + if (state.bgRgb) { + const mbg = /rgb\((\d+),(\d+),(\d+)\)/.exec(state.bgRgb); + if (mbg) { + codes.push(48, 2, +mbg[1], +mbg[2], +mbg[3]); + } + } else if (state.bg && bgMap[state.bg] !== undefined) { + codes.push(bgMap[state.bg]); + } + + if (codes.length === 0) { return ''; } + return ESC + '[' + codes.join(';') + 'm'; +} + +// Process an array of SGR codes +function ansiApplyCodes(state: AnsiState, codes: number[]): void { + for (let ci = 0; ci < codes.length; ci++) { + const code = codes[ci]; + + // Extended foreground: 38;5;n or 38;2;r;g;b + if (code === 38 && ci + 1 < codes.length) { + if (codes[ci + 1] === 5) { + if (ci + 2 < codes.length) { + const idx = codes[ci + 2]; + if (idx >= 0 && idx <= 255 && ANSI_256[idx]) { + state.fg = null; + state.fgRgb = ANSI_256[idx]; + } + ci += 2; + } else { + ci += 1; + } + continue; + } + if (codes[ci + 1] === 2) { + if (ci + 4 < codes.length) { + state.fg = null; + const r = Math.max(0, Math.min(255, codes[ci + 2])); + const g = Math.max(0, Math.min(255, codes[ci + 3])); + const b = Math.max(0, Math.min(255, codes[ci + 4])); + state.fgRgb = 'rgb(' + r + ',' + g + ',' + b + ')'; + ci += 4; + } else { + ci = codes.length - 1; + } + continue; + } + } + + // Extended background: 48;5;n or 48;2;r;g;b + if (code === 48 && ci + 1 < codes.length) { + if (codes[ci + 1] === 5) { + if (ci + 2 < codes.length) { + const idx = codes[ci + 2]; + if (idx >= 0 && idx <= 255 && ANSI_256[idx]) { + state.bg = null; + state.bgRgb = ANSI_256[idx]; + } + ci += 2; + } else { + ci += 1; + } + continue; + } + if (codes[ci + 1] === 2) { + if (ci + 4 < codes.length) { + state.bg = null; + const r = Math.max(0, Math.min(255, codes[ci + 2])); + const g = Math.max(0, Math.min(255, codes[ci + 3])); + const b = Math.max(0, Math.min(255, codes[ci + 4])); + state.bgRgb = 'rgb(' + r + ',' + g + ',' + b + ')'; + ci += 4; + } else { + ci = codes.length - 1; + } + continue; + } + } + + switch (code) { + case 0: + resetAnsiState(state); + break; + case 1: + state.bold = true; + break; + case 2: + state.dim = true; + break; + case 3: + state.italic = true; + break; + case 4: + state.underline = true; + break; + case 5: + state.blink = true; + state.fastBlink = false; + break; + case 6: + state.fastBlink = true; + state.blink = false; + break; + case 7: + state.reverse = true; + break; + case 8: + state.hidden = true; + break; + case 9: + state.strikethrough = true; + break; + case 22: + state.bold = false; + state.dim = false; + break; + case 23: + state.italic = false; + break; + case 24: + state.underline = false; + break; + case 25: + state.blink = false; + state.fastBlink = false; + break; + case 27: + state.reverse = false; + break; + case 28: + state.hidden = false; + break; + case 29: + state.strikethrough = false; + break; + case 30: + state.fg = 'black'; + state.fgRgb = null; + break; + case 31: + state.fg = 'red'; + state.fgRgb = null; + break; + case 32: + state.fg = 'green'; + state.fgRgb = null; + break; + case 33: + state.fg = 'yellow'; + state.fgRgb = null; + break; + case 34: + state.fg = 'blue'; + state.fgRgb = null; + break; + case 35: + state.fg = 'magenta'; + state.fgRgb = null; + break; + case 36: + state.fg = 'cyan'; + state.fgRgb = null; + break; + case 37: + state.fg = 'white'; + state.fgRgb = null; + break; + case 39: + state.fg = null; + state.fgRgb = null; + break; + case 40: + state.bg = 'black'; + state.bgRgb = null; + break; + case 41: + state.bg = 'red'; + state.bgRgb = null; + break; + case 42: + state.bg = 'green'; + state.bgRgb = null; + break; + case 43: + state.bg = 'yellow'; + state.bgRgb = null; + break; + case 44: + state.bg = 'blue'; + state.bgRgb = null; + break; + case 45: + state.bg = 'magenta'; + state.bgRgb = null; + break; + case 46: + state.bg = 'cyan'; + state.bgRgb = null; + break; + case 47: + state.bg = 'white'; + state.bgRgb = null; + break; + case 49: + state.bg = null; + state.bgRgb = null; + break; + // Bright foreground colors (90-97) + case 90: + state.fg = null; + state.fgRgb = ANSI_256[8]; + break; + case 91: + state.fg = null; + state.fgRgb = ANSI_256[9]; + break; + case 92: + state.fg = null; + state.fgRgb = ANSI_256[10]; + break; + case 93: + state.fg = null; + state.fgRgb = ANSI_256[11]; + break; + case 94: + state.fg = null; + state.fgRgb = ANSI_256[12]; + break; + case 95: + state.fg = null; + state.fgRgb = ANSI_256[13]; + break; + case 96: + state.fg = null; + state.fgRgb = ANSI_256[14]; + break; + case 97: + state.fg = null; + state.fgRgb = ANSI_256[15]; + break; + // Bright background colors (100-107) + case 100: + state.bg = null; + state.bgRgb = ANSI_256[8]; + break; + case 101: + state.bg = null; + state.bgRgb = ANSI_256[9]; + break; + case 102: + state.bg = null; + state.bgRgb = ANSI_256[10]; + break; + case 103: + state.bg = null; + state.bgRgb = ANSI_256[11]; + break; + case 104: + state.bg = null; + state.bgRgb = ANSI_256[12]; + break; + case 105: + state.bg = null; + state.bgRgb = ANSI_256[13]; + break; + case 106: + state.bg = null; + state.bgRgb = ANSI_256[14]; + break; + case 107: + state.bg = null; + state.bgRgb = ANSI_256[15]; + break; + } + } +} + +// Create a fresh ANSI state object +function createAnsiState(): AnsiState { + return { + bold: false, + dim: false, + italic: false, + underline: false, + strikethrough: false, + blink: false, + fastBlink: false, + hidden: false, + reverse: false, + fg: null, + bg: null, + fgRgb: null, + bgRgb: null, + }; +} + +describe('ANSI Color Support', () => { + let state: AnsiState; + + beforeEach(() => { + state = createAnsiState(); + }); + + describe('Text Styles', () => { + it('should apply bold style (code 1)', () => { + ansiApplyCodes(state, [1]); + expect(state.bold).toBe(true); + expect(state.dim).toBe(false); + }); + + it('should apply dim style (code 2)', () => { + ansiApplyCodes(state, [2]); + expect(state.dim).toBe(true); + expect(state.bold).toBe(false); + }); + + it('should apply italic style (code 3)', () => { + ansiApplyCodes(state, [3]); + expect(state.italic).toBe(true); + }); + + it('should apply underline style (code 4)', () => { + ansiApplyCodes(state, [4]); + expect(state.underline).toBe(true); + }); + + it('should apply blink style (code 5)', () => { + ansiApplyCodes(state, [5]); + expect(state.blink).toBe(true); + expect(state.fastBlink).toBe(false); + }); + + it('should apply fast blink style (code 6)', () => { + ansiApplyCodes(state, [6]); + expect(state.fastBlink).toBe(true); + expect(state.blink).toBe(false); + }); + + it('should apply reverse style (code 7)', () => { + ansiApplyCodes(state, [7]); + expect(state.reverse).toBe(true); + }); + + it('should apply hidden style (code 8)', () => { + ansiApplyCodes(state, [8]); + expect(state.hidden).toBe(true); + }); + + it('should apply strikethrough style (code 9)', () => { + ansiApplyCodes(state, [9]); + expect(state.strikethrough).toBe(true); + }); + + it('should handle all style codes in single sequence', () => { + ansiApplyCodes(state, [1, 2, 3, 4, 5, 7, 8, 9]); + expect(state.bold).toBe(true); + expect(state.dim).toBe(true); + expect(state.italic).toBe(true); + expect(state.underline).toBe(true); + expect(state.blink).toBe(true); + expect(state.reverse).toBe(true); + expect(state.hidden).toBe(true); + expect(state.strikethrough).toBe(true); + }); + }); + + describe('Style Reset Codes', () => { + beforeEach(() => { + // Set all styles + ansiApplyCodes(state, [1, 2, 3, 4, 5, 7, 8, 9]); + }); + + it('should reset bold and dim with code 22', () => { + ansiApplyCodes(state, [22]); + expect(state.bold).toBe(false); + expect(state.dim).toBe(false); + expect(state.italic).toBe(true); // others unchanged + }); + + it('should reset italic with code 23', () => { + ansiApplyCodes(state, [23]); + expect(state.italic).toBe(false); + expect(state.bold).toBe(true); // others unchanged + }); + + it('should reset underline with code 24', () => { + ansiApplyCodes(state, [24]); + expect(state.underline).toBe(false); + }); + + it('should reset all blink styles with code 25', () => { + ansiApplyCodes(state, [6]); // set fast blink + ansiApplyCodes(state, [25]); + expect(state.blink).toBe(false); + expect(state.fastBlink).toBe(false); + }); + + it('should reset reverse with code 27', () => { + ansiApplyCodes(state, [27]); + expect(state.reverse).toBe(false); + }); + + it('should reset hidden with code 28', () => { + ansiApplyCodes(state, [28]); + expect(state.hidden).toBe(false); + }); + + it('should reset strikethrough with code 29', () => { + ansiApplyCodes(state, [29]); + expect(state.strikethrough).toBe(false); + }); + + it('should reset all styles with code 0', () => { + // Also set some colors + ansiApplyCodes(state, [31, 42]); + ansiApplyCodes(state, [0]); + + expect(state.bold).toBe(false); + expect(state.dim).toBe(false); + expect(state.italic).toBe(false); + expect(state.underline).toBe(false); + expect(state.blink).toBe(false); + expect(state.fastBlink).toBe(false); + expect(state.reverse).toBe(false); + expect(state.hidden).toBe(false); + expect(state.strikethrough).toBe(false); + expect(state.fg).toBeNull(); + expect(state.bg).toBeNull(); + expect(state.fgRgb).toBeNull(); + expect(state.bgRgb).toBeNull(); + }); + }); + + describe('Standard Foreground Colors (30-37)', () => { + it('should set foreground black (code 30)', () => { + ansiApplyCodes(state, [30]); + expect(state.fg).toBe('black'); + expect(state.fgRgb).toBeNull(); + }); + + it('should set foreground red (code 31)', () => { + ansiApplyCodes(state, [31]); + expect(state.fg).toBe('red'); + }); + + it('should set foreground green (code 32)', () => { + ansiApplyCodes(state, [32]); + expect(state.fg).toBe('green'); + }); + + it('should set foreground yellow (code 33)', () => { + ansiApplyCodes(state, [33]); + expect(state.fg).toBe('yellow'); + }); + + it('should set foreground blue (code 34)', () => { + ansiApplyCodes(state, [34]); + expect(state.fg).toBe('blue'); + }); + + it('should set foreground magenta (code 35)', () => { + ansiApplyCodes(state, [35]); + expect(state.fg).toBe('magenta'); + }); + + it('should set foreground cyan (code 36)', () => { + ansiApplyCodes(state, [36]); + expect(state.fg).toBe('cyan'); + }); + + it('should set foreground white (code 37)', () => { + ansiApplyCodes(state, [37]); + expect(state.fg).toBe('white'); + }); + + it('should reset foreground with code 39', () => { + ansiApplyCodes(state, [31]); + ansiApplyCodes(state, [39]); + expect(state.fg).toBeNull(); + expect(state.fgRgb).toBeNull(); + }); + }); + + describe('Standard Background Colors (40-47)', () => { + it('should set background black (code 40)', () => { + ansiApplyCodes(state, [40]); + expect(state.bg).toBe('black'); + expect(state.bgRgb).toBeNull(); + }); + + it('should set background red (code 41)', () => { + ansiApplyCodes(state, [41]); + expect(state.bg).toBe('red'); + }); + + it('should set background green (code 42)', () => { + ansiApplyCodes(state, [42]); + expect(state.bg).toBe('green'); + }); + + it('should set background yellow (code 43)', () => { + ansiApplyCodes(state, [43]); + expect(state.bg).toBe('yellow'); + }); + + it('should set background blue (code 44)', () => { + ansiApplyCodes(state, [44]); + expect(state.bg).toBe('blue'); + }); + + it('should set background magenta (code 45)', () => { + ansiApplyCodes(state, [45]); + expect(state.bg).toBe('magenta'); + }); + + it('should set background cyan (code 46)', () => { + ansiApplyCodes(state, [46]); + expect(state.bg).toBe('cyan'); + }); + + it('should set background white (code 47)', () => { + ansiApplyCodes(state, [47]); + expect(state.bg).toBe('white'); + }); + + it('should reset background with code 49', () => { + ansiApplyCodes(state, [41]); + ansiApplyCodes(state, [49]); + expect(state.bg).toBeNull(); + expect(state.bgRgb).toBeNull(); + }); + }); + + describe('Bright Foreground Colors (90-97)', () => { + it('should set bright foreground colors using 256-color palette', () => { + ansiApplyCodes(state, [90]); + expect(state.fg).toBeNull(); + expect(state.fgRgb).toBe(ANSI_256[8]); + + ansiApplyCodes(state, [91]); + expect(state.fgRgb).toBe(ANSI_256[9]); + + ansiApplyCodes(state, [92]); + expect(state.fgRgb).toBe(ANSI_256[10]); + + ansiApplyCodes(state, [93]); + expect(state.fgRgb).toBe(ANSI_256[11]); + + ansiApplyCodes(state, [94]); + expect(state.fgRgb).toBe(ANSI_256[12]); + + ansiApplyCodes(state, [95]); + expect(state.fgRgb).toBe(ANSI_256[13]); + + ansiApplyCodes(state, [96]); + expect(state.fgRgb).toBe(ANSI_256[14]); + + ansiApplyCodes(state, [97]); + expect(state.fgRgb).toBe(ANSI_256[15]); + }); + }); + + describe('Bright Background Colors (100-107)', () => { + it('should set bright background colors using 256-color palette', () => { + ansiApplyCodes(state, [100]); + expect(state.bg).toBeNull(); + expect(state.bgRgb).toBe(ANSI_256[8]); + + ansiApplyCodes(state, [101]); + expect(state.bgRgb).toBe(ANSI_256[9]); + + ansiApplyCodes(state, [102]); + expect(state.bgRgb).toBe(ANSI_256[10]); + + ansiApplyCodes(state, [103]); + expect(state.bgRgb).toBe(ANSI_256[11]); + + ansiApplyCodes(state, [104]); + expect(state.bgRgb).toBe(ANSI_256[12]); + + ansiApplyCodes(state, [105]); + expect(state.bgRgb).toBe(ANSI_256[13]); + + ansiApplyCodes(state, [106]); + expect(state.bgRgb).toBe(ANSI_256[14]); + + ansiApplyCodes(state, [107]); + expect(state.bgRgb).toBe(ANSI_256[15]); + }); + }); + + describe('256-Color Palette (38;5;n and 48;5;n)', () => { + it('should set foreground using 256-color palette index', () => { + ansiApplyCodes(state, [38, 5, 196]); // Bright red from color cube + expect(state.fg).toBeNull(); + expect(state.fgRgb).toBe(ANSI_256[196]); + }); + + it('should set background using 256-color palette index', () => { + ansiApplyCodes(state, [48, 5, 46]); // Green from color cube + expect(state.bg).toBeNull(); + expect(state.bgRgb).toBe(ANSI_256[46]); + }); + + it('should handle standard colors (0-7) via 256-color syntax', () => { + ansiApplyCodes(state, [38, 5, 1]); // Red + expect(state.fgRgb).toBe(ANSI_256[1]); + }); + + it('should handle bright colors (8-15) via 256-color syntax', () => { + ansiApplyCodes(state, [38, 5, 9]); // Bright red + expect(state.fgRgb).toBe(ANSI_256[9]); + }); + + it('should handle color cube colors (16-231)', () => { + ansiApplyCodes(state, [38, 5, 16]); // First color cube color + expect(state.fgRgb).toBe(ANSI_256[16]); + + ansiApplyCodes(state, [38, 5, 231]); // Last color cube color + expect(state.fgRgb).toBe(ANSI_256[231]); + }); + + it('should handle grayscale ramp (232-255)', () => { + ansiApplyCodes(state, [38, 5, 232]); // First grayscale + expect(state.fgRgb).toBe(ANSI_256[232]); + + ansiApplyCodes(state, [48, 5, 255]); // Last grayscale + expect(state.bgRgb).toBe(ANSI_256[255]); + }); + + it('should ignore out-of-range palette indices', () => { + ansiApplyCodes(state, [38, 5, 300]); // Out of range + // Should not crash and state should remain unchanged from default + expect(state.fgRgb).toBeNull(); + }); + + it('should ignore incomplete 256-color sequences', () => { + ansiApplyCodes(state, [38, 5]); // Missing index + expect(state.fg).toBeNull(); + expect(state.fgRgb).toBeNull(); + }); + }); + + describe('Truecolor RGB (38;2;r;g;b and 48;2;r;g;b)', () => { + it('should set foreground truecolor RGB', () => { + ansiApplyCodes(state, [38, 2, 255, 128, 64]); + expect(state.fg).toBeNull(); + expect(state.fgRgb).toBe('rgb(255,128,64)'); + }); + + it('should set background truecolor RGB', () => { + ansiApplyCodes(state, [48, 2, 64, 128, 255]); + expect(state.bg).toBeNull(); + expect(state.bgRgb).toBe('rgb(64,128,255)'); + }); + + it('should clamp RGB values to valid range', () => { + ansiApplyCodes(state, [38, 2, 300, -50, 255]); + expect(state.fgRgb).toBe('rgb(255,0,255)'); + }); + + it('should handle black RGB values', () => { + ansiApplyCodes(state, [38, 2, 0, 0, 0]); + expect(state.fgRgb).toBe('rgb(0,0,0)'); + }); + + it('should handle white RGB values', () => { + ansiApplyCodes(state, [38, 2, 255, 255, 255]); + expect(state.fgRgb).toBe('rgb(255,255,255)'); + }); + + it('should ignore incomplete truecolor sequences', () => { + ansiApplyCodes(state, [38, 2, 255, 128]); // Missing blue component + expect(state.fgRgb).toBeNull(); + }); + + it('should handle multiple RGB codes in sequence', () => { + ansiApplyCodes(state, [38, 2, 100, 150, 200, 48, 2, 50, 75, 100]); + expect(state.fgRgb).toBe('rgb(100,150,200)'); + expect(state.bgRgb).toBe('rgb(50,75,100)'); + }); + }); + + describe('Combined Sequences', () => { + it('should handle foreground color with style', () => { + ansiApplyCodes(state, [1, 31]); // Bold red + expect(state.bold).toBe(true); + expect(state.fg).toBe('red'); + }); + + it('should handle foreground and background with styles', () => { + ansiApplyCodes(state, [1, 3, 31, 42]); // Bold italic red on green + expect(state.bold).toBe(true); + expect(state.italic).toBe(true); + expect(state.fg).toBe('red'); + expect(state.bg).toBe('green'); + }); + + it('should handle 256-color with styles', () => { + ansiApplyCodes(state, [1, 2, 38, 5, 196]); // Bold dim with bright red + expect(state.bold).toBe(true); + expect(state.dim).toBe(true); + expect(state.fgRgb).toBe(ANSI_256[196]); + }); + + it('should handle truecolor with styles', () => { + ansiApplyCodes(state, [4, 38, 2, 255, 128, 0, 48, 2, 0, 0, 255]); // Underline orange on blue + expect(state.underline).toBe(true); + expect(state.fgRgb).toBe('rgb(255,128,0)'); + expect(state.bgRgb).toBe('rgb(0,0,255)'); + }); + + it('should handle reset followed by new styles', () => { + ansiApplyCodes(state, [1, 31, 42]); // Bold red on green + ansiApplyCodes(state, [0, 4, 34]); // Reset, then underline blue + expect(state.bold).toBe(false); + expect(state.fg).toBe('blue'); + expect(state.bg).toBeNull(); + expect(state.underline).toBe(true); + }); + + it('should handle partial reset in sequence', () => { + ansiApplyCodes(state, [1, 3, 4, 31]); // Bold italic underline red + ansiApplyCodes(state, [22, 24]); // Reset bold/dim and underline + expect(state.bold).toBe(false); + expect(state.italic).toBe(true); // Preserved + expect(state.underline).toBe(false); + expect(state.fg).toBe('red'); // Preserved + }); + }); + + describe('ANSI State Serialization (ansiStateToSgr)', () => { + it('should return empty string for default state', () => { + const sgr = ansiStateToSgr(state); + expect(sgr).toBe(''); + }); + + it('should serialize bold style', () => { + state.bold = true; + expect(ansiStateToSgr(state)).toBe('\x1b[1m'); + }); + + it('should serialize multiple styles', () => { + state.bold = true; + state.italic = true; + expect(ansiStateToSgr(state)).toBe('\x1b[1;3m'); + }); + + it('should serialize foreground color', () => { + state.fg = 'red'; + expect(ansiStateToSgr(state)).toBe('\x1b[31m'); + }); + + it('should serialize background color', () => { + state.bg = 'blue'; + expect(ansiStateToSgr(state)).toBe('\x1b[44m'); + }); + + it('should serialize truecolor foreground', () => { + state.fgRgb = 'rgb(255,128,64)'; + expect(ansiStateToSgr(state)).toBe('\x1b[38;2;255;128;64m'); + }); + + it('should serialize truecolor background', () => { + state.bgRgb = 'rgb(64,128,255)'; + expect(ansiStateToSgr(state)).toBe('\x1b[48;2;64;128;255m'); + }); + + it('should serialize complex state', () => { + state.bold = true; + state.underline = true; + state.fg = 'green'; + state.bgRgb = 'rgb(128,0,0)'; + const sgr = ansiStateToSgr(state); + expect(sgr).toBe('\x1b[1;4;32;48;2;128;0;0m'); + }); + + it('should serialize all styles', () => { + state.bold = true; + state.dim = true; + state.italic = true; + state.underline = true; + state.blink = true; + state.reverse = true; + state.hidden = true; + state.strikethrough = true; + expect(ansiStateToSgr(state)).toBe('\x1b[1;2;3;4;5;7;8;9m'); + }); + }); + + describe('State Reset Function', () => { + it('should reset all properties to defaults', () => { + // Set everything to non-default + ansiApplyCodes(state, [1, 2, 3, 4, 5, 6, 7, 8, 9, 31, 42]); + expect(state.fastBlink).toBe(true); // 6 sets fast blink, cancels blink + + // Set both for testing reset + ansiApplyCodes(state, [5]); // Add blink back + + resetAnsiState(state); + + expect(state.bold).toBe(false); + expect(state.dim).toBe(false); + expect(state.italic).toBe(false); + expect(state.underline).toBe(false); + expect(state.blink).toBe(false); + expect(state.fastBlink).toBe(false); + expect(state.reverse).toBe(false); + expect(state.hidden).toBe(false); + expect(state.strikethrough).toBe(false); + expect(state.fg).toBeNull(); + expect(state.bg).toBeNull(); + expect(state.fgRgb).toBeNull(); + expect(state.bgRgb).toBeNull(); + }); + }); + + describe('Edge Cases and Error Handling', () => { + it('should ignore unknown SGR codes', () => { + ansiApplyCodes(state, [99, 1, 999]); // Unknown codes mixed with valid + expect(state.bold).toBe(true); // Valid code still applied + }); + + it('should handle empty code array', () => { + ansiApplyCodes(state, []); + expect(state).toEqual(createAnsiState()); + }); + + it('should handle single code 0 (reset)', () => { + ansiApplyCodes(state, [1, 31]); // Set some state + ansiApplyCodes(state, [0]); + expect(state).toEqual(createAnsiState()); + }); + + it('should handle code 0 in multi-code sequence', () => { + ansiApplyCodes(state, [1, 0, 31]); // Reset then red + expect(state.bold).toBe(false); + expect(state.fg).toBe('red'); + }); + + it('should switch between standard and 256-color modes', () => { + ansiApplyCodes(state, [31]); // Standard red + expect(state.fg).toBe('red'); + expect(state.fgRgb).toBeNull(); + + ansiApplyCodes(state, [38, 5, 196]); // 256-color bright red + expect(state.fg).toBeNull(); + expect(state.fgRgb).toBe(ANSI_256[196]); + + ansiApplyCodes(state, [31]); // Back to standard red + expect(state.fg).toBe('red'); + expect(state.fgRgb).toBeNull(); + }); + + it('should switch between 256-color and truecolor modes', () => { + ansiApplyCodes(state, [38, 5, 196]); // 256-color + expect(state.fgRgb).toBe(ANSI_256[196]); + + ansiApplyCodes(state, [38, 2, 255, 0, 0]); // Truecolor + expect(state.fgRgb).toBe('rgb(255,0,0)'); + }); + + it('should handle blink mutually exclusive properly', () => { + ansiApplyCodes(state, [5]); // Slow blink + expect(state.blink).toBe(true); + expect(state.fastBlink).toBe(false); + + ansiApplyCodes(state, [6]); // Fast blink - should cancel slow + expect(state.blink).toBe(false); + expect(state.fastBlink).toBe(true); + + ansiApplyCodes(state, [5]); // Slow blink - should cancel fast + expect(state.blink).toBe(true); + expect(state.fastBlink).toBe(false); + }); + }); + + describe('256-Color Palette Accuracy', () => { + it('should have correct standard colors (0-7)', () => { + expect(ANSI_256[0]).toBe('rgb(0,0,0)'); // Black + expect(ANSI_256[1]).toBe('rgb(128,0,0)'); // Red + expect(ANSI_256[2]).toBe('rgb(0,128,0)'); // Green + expect(ANSI_256[3]).toBe('rgb(128,128,0)'); // Yellow + expect(ANSI_256[4]).toBe('rgb(0,0,128)'); // Blue + expect(ANSI_256[5]).toBe('rgb(128,0,128)'); // Magenta + expect(ANSI_256[6]).toBe('rgb(0,128,128)'); // Cyan + expect(ANSI_256[7]).toBe('rgb(192,192,192)'); // White + }); + + it('should have correct bright colors (8-15)', () => { + expect(ANSI_256[8]).toBe('rgb(128,128,128)'); // Bright black (gray) + expect(ANSI_256[9]).toBe('rgb(255,0,0)'); // Bright red + expect(ANSI_256[10]).toBe('rgb(0,255,0)'); // Bright green + expect(ANSI_256[11]).toBe('rgb(255,255,0)'); // Bright yellow + expect(ANSI_256[12]).toBe('rgb(99,153,255)'); // Bright blue + expect(ANSI_256[13]).toBe('rgb(255,0,255)'); // Bright magenta + expect(ANSI_256[14]).toBe('rgb(0,255,255)'); // Bright cyan + expect(ANSI_256[15]).toBe('rgb(255,255,255)'); // Bright white + }); + + it('should generate correct color cube values', () => { + // First color in cube (16) - all channels at 0 intensity + expect(ANSI_256[16]).toBe('rgb(0,0,0)'); + + // Color with red at intensity 1 (16 + 36) + expect(ANSI_256[52]).toBe('rgb(95,0,0)'); + + // Color with green at intensity 1 (16 + 6) + expect(ANSI_256[22]).toBe('rgb(0,95,0)'); + + // Color with blue at intensity 1 (16 + 1) + expect(ANSI_256[17]).toBe('rgb(0,0,95)'); + + // Maximum color (16 + 215 = 231) - all channels at max + expect(ANSI_256[231]).toBe('rgb(255,255,255)'); + }); + + it('should generate correct grayscale ramp values', () => { + // First grayscale (232) - should be rgb(8,8,8) + expect(ANSI_256[232]).toBe('rgb(8,8,8)'); + + // Middle grayscale (around 243) + expect(ANSI_256[243]).toBe('rgb(118,118,118)'); + + // Last grayscale (255) - should be rgb(238,238,238) + expect(ANSI_256[255]).toBe('rgb(238,238,238)'); + }); + + it('should have exactly 256 colors', () => { + expect(ANSI_256.length).toBe(256); + }); + }); + + describe('Complex Real-World Scenarios', () => { + it('should handle ESP32 log color scheme', () => { + // ESP32 log levels typically use colors: + // ERROR - red, WARN - yellow, INFO - green, DEBUG - cyan, VERBOSE - gray + + // ERROR: bold red + ansiApplyCodes(state, [1, 31]); + expect(state.bold).toBe(true); + expect(state.fg).toBe('red'); + resetAnsiState(state); + + // WARN: bold yellow + ansiApplyCodes(state, [1, 33]); + expect(state.bold).toBe(true); + expect(state.fg).toBe('yellow'); + resetAnsiState(state); + + // INFO: green + ansiApplyCodes(state, [32]); + expect(state.fg).toBe('green'); + resetAnsiState(state); + + // DEBUG: cyan + ansiApplyCodes(state, [36]); + expect(state.fg).toBe('cyan'); + resetAnsiState(state); + + // VERBOSE: dim gray (using 256-color) + ansiApplyCodes(state, [2, 38, 5, 8]); + expect(state.dim).toBe(true); + expect(state.fgRgb).toBe(ANSI_256[8]); + }); + + it('should handle syntax highlighting patterns', () => { + // Keywords: bold blue + ansiApplyCodes(state, [1, 34]); + expect(state.bold && state.fg === 'blue').toBe(true); + resetAnsiState(state); + + // Strings: green + ansiApplyCodes(state, [32]); + expect(state.fg).toBe('green'); + resetAnsiState(state); + + // Comments: dim gray (italic) + ansiApplyCodes(state, [2, 3, 90]); + expect(state.dim).toBe(true); + expect(state.italic).toBe(true); + expect(state.fgRgb).toBe(ANSI_256[8]); + resetAnsiState(state); + + // Numbers: magenta + ansiApplyCodes(state, [35]); + expect(state.fg).toBe('magenta'); + }); + + it('should handle timestamp prefix with preserved state', () => { + // Original state: bold red + ansiApplyCodes(state, [1, 31]); + + // Save state + const savedState = ansiStateToSgr(state); + expect(savedState).toBe('\x1b[1;31m'); + + // Reset for timestamp (dim) + ansiApplyCodes(state, [0, 2]); + expect(state.dim).toBe(true); + expect(state.bold).toBe(false); + + // Restore original state + // Parse the saved SGR sequence + const match = savedState.match(/^\x1b\[(.*)m$/); + if (match) { + const codes = match[1].split(';').map((c) => parseInt(c, 10) || 0); + ansiApplyCodes(state, [0]); // Reset first + ansiApplyCodes(state, codes); + } + + expect(state.bold).toBe(true); + expect(state.fg).toBe('red'); + expect(state.dim).toBe(false); + }); + + it('should handle multi-line colored output state preservation', () => { + // Set up a complex state + ansiApplyCodes(state, [1, 3, 38, 2, 255, 128, 0]); // Bold italic orange + + // Verify initial state + expect(state.bold).toBe(true); + expect(state.italic).toBe(true); + expect(state.fgRgb).toBe('rgb(255,128,0)'); + + // Serialize state + const sgr = ansiStateToSgr(state); + + // Simulate reset for timestamp + ansiApplyCodes(state, [0, 2]); + + // Restore + const match = sgr.match(/^\x1b\[(.*)m$/); + if (match) { + const codes = match[1].split(';').map((c) => parseInt(c, 10) || 0); + ansiApplyCodes(state, [0]); + ansiApplyCodes(state, codes); + } + + // State should be fully restored + expect(state.bold).toBe(true); + expect(state.italic).toBe(true); + expect(state.fgRgb).toBe('rgb(255,128,0)'); + }); + }); +}); From 7a58893037578f7e6325bfc152087cd7d54a5f43 Mon Sep 17 00:00:00 2001 From: Jason2866 <24528715+Jason2866@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:30:39 +0200 Subject: [PATCH 06/13] Add tests for file opening and regex matching --- src/test/webviewPanel.test.ts | 553 ++++++++++++++++++++++++++++++++++ 1 file changed, 553 insertions(+) diff --git a/src/test/webviewPanel.test.ts b/src/test/webviewPanel.test.ts index 6142864..10d13cf 100644 --- a/src/test/webviewPanel.test.ts +++ b/src/test/webviewPanel.test.ts @@ -369,5 +369,558 @@ describe('EspDecoderWebviewPanel – PR #42 file opening', () => { expect(mockOpenTextDocument).not.toHaveBeenCalled(); expect(mockShowTextDocument).not.toHaveBeenCalled(); }); + + it('opens file with line and column when both provided (happy path)', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleMessage = (panel as any).handleMessage.bind(panel); + mockOpenTextDocument.mockResolvedValue({ uri: { fsPath: '/workspace/src/main.cpp' } }); + + await handleMessage({ + type: 'openFile', + file: '/workspace/src/main.cpp', + line: '42', + column: '15', + }); + + expect(mockOpenTextDocument).toHaveBeenCalledWith({ fsPath: '/workspace/src/main.cpp' }); + expect(mockShowTextDocument).toHaveBeenCalledWith( + { uri: { fsPath: '/workspace/src/main.cpp' } }, + expect.objectContaining({ + selection: expect.objectContaining({ + start: { line: 41, character: 14 }, + end: { line: 41, character: 14 }, + }), + }) + ); + }); + + it('opens file with line only when column is missing', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleMessage = (panel as any).handleMessage.bind(panel); + mockOpenTextDocument.mockResolvedValue({ uri: { fsPath: '/workspace/src/main.cpp' } }); + + await handleMessage({ + type: 'openFile', + file: '/workspace/src/main.cpp', + line: '42', + }); + + expect(mockShowTextDocument).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + selection: expect.objectContaining({ + start: { line: 41, character: 0 }, + end: { line: 41, character: 0 }, + }), + }) + ); + }); + }); +}); + +describe('SERIAL_LINK_RE regex matching', () => { + // Regex matching file:line[:col] references in serial output + // Matches anchored paths (with drive letter, leading / or ./ ../) allowing spaces, + // and plain relative paths without spaces + // Captures: 1=path, 2=line, 3=col? + const SERIAL_LINK_RE = /((?:(?:[A-Za-z]:[\\/]|[\\/]|\.\.?[\\/])[\w./\\ -]+|[\w.-]+(?:[\\/][\w.-]+)*)\.(?:c|cc|cpp|cxx|h|hh|hpp|hxx|ino|s|asm|tcc|ipp)):(\d+)(?::(\d+))?/gi; + + function getAllMatches(text: string): Array<{ path: string; line: string; col?: string; full: string }> { + const matches: Array<{ path: string; line: string; col?: string; full: string }> = []; + SERIAL_LINK_RE.lastIndex = 0; + let match; + while ((match = SERIAL_LINK_RE.exec(text)) !== null) { + matches.push({ + path: match[1], + line: match[2], + col: match[3], + full: match[0], + }); + } + return matches; + } + + it('matches simple relative path with line number', () => { + const matches = getAllMatches('src/main.cpp:42'); + expect(matches).toHaveLength(1); + expect(matches[0]).toMatchObject({ path: 'src/main.cpp', line: '42', full: 'src/main.cpp:42' }); + }); + + it('matches relative path with line and column', () => { + const matches = getAllMatches('src/main.cpp:42:15'); + expect(matches).toHaveLength(1); + expect(matches[0]).toMatchObject({ path: 'src/main.cpp', line: '42', col: '15', full: 'src/main.cpp:42:15' }); + }); + + it('matches absolute Unix path', () => { + const matches = getAllMatches('/home/user/project/src/main.cpp:100'); + expect(matches).toHaveLength(1); + expect(matches[0]).toMatchObject({ path: '/home/user/project/src/main.cpp', line: '100' }); + }); + + it('matches Windows path with drive letter', () => { + const matches = getAllMatches('C:\\Users\\me\\Project\\main.cpp:42'); + expect(matches).toHaveLength(1); + expect(matches[0]).toMatchObject({ path: 'C:\\Users\\me\\Project\\main.cpp', line: '42' }); + }); + + it('matches path with ./ prefix', () => { + const matches = getAllMatches('./src/utils/helper.cpp:25'); + expect(matches).toHaveLength(1); + expect(matches[0]).toMatchObject({ path: './src/utils/helper.cpp', line: '25' }); + }); + + it('matches path with ../ prefix', () => { + const matches = getAllMatches('../include/header.h:10:5'); + expect(matches).toHaveLength(1); + expect(matches[0]).toMatchObject({ path: '../include/header.h', line: '10', col: '5' }); + }); + + it('matches path containing spaces (anchored paths only)', () => { + const matches = getAllMatches('/home/user/My Project/src/main.cpp:42'); + expect(matches).toHaveLength(1); + expect(matches[0]).toMatchObject({ path: '/home/user/My Project/src/main.cpp', line: '42' }); + }); + + it('matches various file extensions', () => { + const extensions = ['c', 'cc', 'cpp', 'cxx', 'h', 'hh', 'hpp', 'hxx', 'ino', 's', 'asm', 'tcc', 'ipp']; + for (const ext of extensions) { + SERIAL_LINK_RE.lastIndex = 0; + const matches = getAllMatches(`file.${ext}:10`); + expect(matches).toHaveLength(1); + expect(matches[0].path).toBe(`file.${ext}`); + } + }); + + it('matches multiple file:line references in same line', () => { + const text = 'Error in src/main.cpp:42 and also in src/utils.cpp:100:5'; + const matches = getAllMatches(text); + expect(matches).toHaveLength(2); + expect(matches[0]).toMatchObject({ path: 'src/main.cpp', line: '42' }); + expect(matches[1]).toMatchObject({ path: 'src/utils.cpp', line: '100', col: '5' }); + }); + + it('does not match paths without recognized extensions', () => { + const matches = getAllMatches('readme.txt:10 or file.py:20'); + expect(matches).toHaveLength(0); + }); + + it('does not match standalone numbers (timestamps)', () => { + const matches = getAllMatches('12:34:56.789 timestamp in log'); + expect(matches).toHaveLength(0); + }); + + it('does not match paths with spaces unless anchored', () => { + // Plain relative paths with spaces should not match + // The regex may match 'Project/main.cpp:42' as a substring of 'My Project/main.cpp:42' + // but not the full path with spaces + const matches = getAllMatches('My Project/main.cpp:42'); + // If it matches, it should be 'Project/main.cpp' not 'My Project/main.cpp' + if (matches.length > 0) { + expect(matches[0].path).not.toContain(' '); + } + }); + + it('matches header file names', () => { + const matches = getAllMatches('WiFi.h:42 and Arduino.h:100'); + expect(matches).toHaveLength(2); + expect(matches[0]).toMatchObject({ path: 'WiFi.h', line: '42' }); + expect(matches[1]).toMatchObject({ path: 'Arduino.h', line: '100' }); + }); + + it('matches ESP-IDF style paths', () => { + const text = '0x400d1234: function at /esp-idf/components/freertos/queue.c:1234'; + const matches = getAllMatches(text); + expect(matches).toHaveLength(1); + expect(matches[0]).toMatchObject({ path: '/esp-idf/components/freertos/queue.c', line: '1234' }); + }); +}); + +describe('Click handler Ctrl/Cmd gate', () => { + it('detects Ctrl+click on serial-file-link', () => { + const mockClosest = vi.fn((selector: string) => { + if (selector === '.serial-file-link') { + return { + getAttribute: (attr: string) => { + if (attr === 'data-file') return '/src/main.cpp'; + if (attr === 'data-line') return '42'; + if (attr === 'data-column') return '15'; + return null; + }, + }; + } + return null; + }); + + const mockEvent = { + ctrlKey: true, + metaKey: false, + target: { closest: mockClosest }, + preventDefault: vi.fn(), + }; + + // Simulate the click handler check from webviewPanel.ts + const serialLink = mockEvent.target.closest('.serial-file-link'); + const hasModifier = mockEvent.ctrlKey || mockEvent.metaKey; + + expect(serialLink).not.toBeNull(); + expect(hasModifier).toBe(true); + expect(serialLink!.getAttribute('data-file')).toBe('/src/main.cpp'); + expect(serialLink!.getAttribute('data-line')).toBe('42'); + expect(serialLink!.getAttribute('data-column')).toBe('15'); + }); + + it('detects Cmd+click on serial-file-link (macOS)', () => { + const mockClosest = vi.fn((selector: string) => { + if (selector === '.serial-file-link') { + return { + getAttribute: (attr: string) => { + if (attr === 'data-file') return '/src/utils.cpp'; + if (attr === 'data-line') return '100'; + return null; + }, + }; + } + return null; + }); + + const mockEvent = { + ctrlKey: false, + metaKey: true, + target: { closest: mockClosest }, + preventDefault: vi.fn(), + }; + + const serialLink = mockEvent.target.closest('.serial-file-link'); + const hasModifier = mockEvent.ctrlKey || mockEvent.metaKey; + + expect(serialLink).not.toBeNull(); + expect(hasModifier).toBe(true); + expect(serialLink!.getAttribute('data-file')).toBe('/src/utils.cpp'); + expect(serialLink!.getAttribute('data-line')).toBe('100'); + }); + + it('does not open file without Ctrl/Cmd modifier', () => { + const mockClosest = vi.fn((selector: string) => { + if (selector === '.serial-file-link') { + return { + getAttribute: (attr: string) => { + if (attr === 'data-file') return '/src/main.cpp'; + if (attr === 'data-line') return '42'; + return null; + }, + }; + } + return null; + }); + + const mockEvent = { + ctrlKey: false, + metaKey: false, + target: { closest: mockClosest }, + }; + + const serialLink = mockEvent.target.closest('.serial-file-link'); + const hasModifier = mockEvent.ctrlKey || mockEvent.metaKey; + + expect(serialLink).not.toBeNull(); + expect(hasModifier).toBe(false); + // The handler should NOT process this click + }); + + it('does nothing when clicking non-link elements', () => { + const mockClosest = vi.fn(() => null); + + const mockEvent = { + ctrlKey: true, + target: { closest: mockClosest }, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const serialLink = (mockEvent.target as any).closest('.serial-file-link'); + expect(serialLink).toBeNull(); + }); +}); + +describe('buildLinkifiedFragment / makeSerialFileLink', () => { + it('returns null for empty text', () => { + const result = null; // In real DOM would be null + expect(result).toBeNull(); + }); + + it('creates link elements with correct attributes', () => { + // Simulating makeSerialFileLink behavior + const mockElement = { + className: '', + attributes: {} as Record, + title: '', + setAttribute: function(key: string, value: string) { + this.attributes[key] = value; + }, + }; + + // Simulate what makeSerialFileLink does + mockElement.className = 'serial-file-link'; + mockElement.setAttribute('data-file', '/src/main.cpp'); + mockElement.setAttribute('data-line', '42'); + mockElement.setAttribute('data-column', '15'); + mockElement.title = 'Ctrl/Cmd+click to open /src/main.cpp:42'; + + expect(mockElement.className).toBe('serial-file-link'); + expect(mockElement.attributes['data-file']).toBe('/src/main.cpp'); + expect(mockElement.attributes['data-line']).toBe('42'); + expect(mockElement.attributes['data-column']).toBe('15'); + }); + + it('creates link without column when not provided', () => { + const mockElement = { + attributes: {} as Record, + setAttribute: function(key: string, value: string) { + this.attributes[key] = value; + }, + }; + + mockElement.setAttribute('data-file', '/src/main.cpp'); + mockElement.setAttribute('data-line', '42'); + // No column attribute set + + expect(mockElement.attributes['data-file']).toBe('/src/main.cpp'); + expect(mockElement.attributes['data-line']).toBe('42'); + expect(mockElement.attributes['data-column']).toBeUndefined(); + }); +}); + +describe('ansiMakeNode integration', () => { + it('returns null for empty text', () => { + const result = null; // Empty text returns null + expect(result).toBeNull(); + }); + + it('detects when span is needed based on ANSI state', () => { + const ansiState = { + bold: false, + italic: false, + underline: false, + strikethrough: false, + blink: false, + fastBlink: false, + hidden: false, + dim: false, + reverse: false, + fg: null, + bg: null, + fgRgb: null, + bgRgb: null, + }; + + // When no ANSI state is set, no span is needed + const needsSpan = ansiState.bold || ansiState.italic || ansiState.underline || + ansiState.strikethrough || ansiState.blink || ansiState.fastBlink || + ansiState.hidden || ansiState.dim || ansiState.reverse || + ansiState.fg || ansiState.bg || ansiState.fgRgb || ansiState.bgRgb; + + // needsSpan will be null (last falsy value) or false when all are falsy + expect(Boolean(needsSpan)).toBe(false); + + // When any ANSI state is set, span is needed + ansiState.bold = true; + const needsSpanWithBold = true; + expect(needsSpanWithBold).toBe(true); + }); + + it('applies all ANSI style classes when set', () => { + const classes: string[] = []; + const ansiState = { + bold: true, + dim: true, + italic: true, + underline: true, + strikethrough: true, + blink: true, + fastBlink: false, + hidden: true, + reverse: false, + }; + + // Simulate classList.add calls from ansiMakeNode + if (ansiState.bold) { classes.push('ansi-bold'); } + if (ansiState.dim) { classes.push('ansi-dim'); } + if (ansiState.italic) { classes.push('ansi-italic'); } + if (ansiState.underline) { classes.push('ansi-underline'); } + if (ansiState.strikethrough) { classes.push('ansi-strikethrough'); } + if (ansiState.blink) { classes.push('ansi-blink'); } + if (ansiState.fastBlink) { classes.push('ansi-blink-fast'); } + if (ansiState.hidden) { classes.push('ansi-hidden'); } + + expect(classes).toContain('ansi-bold'); + expect(classes).toContain('ansi-dim'); + expect(classes).toContain('ansi-italic'); + expect(classes).toContain('ansi-underline'); + expect(classes).toContain('ansi-strikethrough'); + expect(classes).toContain('ansi-blink'); + expect(classes).toContain('ansi-hidden'); + expect(classes).not.toContain('ansi-blink-fast'); // Not set + expect(classes).not.toContain('ansi-reverse'); // Not set + }); + + it('handles reverse video mode correctly', () => { + const ansiState = { + reverse: true, + fg: 'red' as string | null, + bg: 'blue' as string | null, + fgRgb: null as string | null, + bgRgb: null as string | null, + }; + + // In reverse mode, fg and bg are swapped + let localFg = ansiState.bg; // Swapped! + let localBg = ansiState.fg; // Swapped! + + expect(localFg).toBe('blue'); + expect(localBg).toBe('red'); + }); + + it('handles reverse with RGB colors', () => { + const ansiState = { + reverse: true, + fg: null as string | null, + bg: null as string | null, + fgRgb: 'rgb(255,0,0)' as string | null, + bgRgb: 'rgb(0,0,255)' as string | null, + }; + + // In reverse mode, RGB colors are swapped + let localFgRgb = ansiState.bgRgb; + let localBgRgb = ansiState.fgRgb; + + expect(localFgRgb).toBe('rgb(0,0,255)'); + expect(localBgRgb).toBe('rgb(255,0,0)'); + }); + + it('applies ansi-reverse class when no colors set in reverse mode', () => { + const ansiState = { + reverse: true, + fg: null, + bg: null, + fgRgb: null, + bgRgb: null, + }; + + // When reverse is set but no colors, use css class + const useReverseClass = !ansiState.fgRgb && !ansiState.fg && + !ansiState.bgRgb && !ansiState.bg; + + expect(useReverseClass).toBe(true); + }); +}); + +describe('Modifier key tracking event listeners', () => { + it('activates mod-link-active on Control keydown', () => { + const mockBody = { classList: { toggle: vi.fn() } }; + const setModLinkActive = (on: boolean) => { + mockBody.classList.toggle('mod-link-active', !!on); + }; + + // Simulate keydown with Control + const mockKeydown = { key: 'Control', ctrlKey: true, metaKey: false }; + if (mockKeydown.key === 'Control' || mockKeydown.key === 'Meta' || mockKeydown.ctrlKey || mockKeydown.metaKey) { + setModLinkActive(true); + } + + expect(mockBody.classList.toggle).toHaveBeenCalledWith('mod-link-active', true); + }); + + it('activates mod-link-active on Meta keydown (Cmd on macOS)', () => { + const mockBody = { classList: { toggle: vi.fn() } }; + const setModLinkActive = (on: boolean) => { + mockBody.classList.toggle('mod-link-active', !!on); + }; + + const mockKeydown = { key: 'Meta', ctrlKey: false, metaKey: true }; + if (mockKeydown.key === 'Control' || mockKeydown.key === 'Meta' || mockKeydown.ctrlKey || mockKeydown.metaKey) { + setModLinkActive(true); + } + + expect(mockBody.classList.toggle).toHaveBeenCalledWith('mod-link-active', true); + }); + + it('deactivates mod-link-active on Control keyup', () => { + const mockBody = { classList: { toggle: vi.fn() } }; + const setModLinkActive = (on: boolean) => { + mockBody.classList.toggle('mod-link-active', !!on); + }; + + const mockKeyup = { key: 'Control', ctrlKey: false, metaKey: false }; + if (mockKeyup.key === 'Control' || mockKeyup.key === 'Meta') { + setModLinkActive(mockKeyup.ctrlKey || mockKeyup.metaKey); + } + + expect(mockBody.classList.toggle).toHaveBeenCalledWith('mod-link-active', false); + }); + + it('deactivates mod-link-active on Meta keyup', () => { + const mockBody = { classList: { toggle: vi.fn() } }; + const setModLinkActive = (on: boolean) => { + mockBody.classList.toggle('mod-link-active', !!on); + }; + + const mockKeyup = { key: 'Meta', ctrlKey: false, metaKey: false }; + if (mockKeyup.key === 'Control' || mockKeyup.key === 'Meta') { + setModLinkActive(mockKeyup.ctrlKey || mockKeyup.metaKey); + } + + expect(mockBody.classList.toggle).toHaveBeenCalledWith('mod-link-active', false); + }); + + it('keeps mod-link-active active when other modifiers still held', () => { + const mockBody = { classList: { toggle: vi.fn() } }; + const setModLinkActive = (on: boolean) => { + mockBody.classList.toggle('mod-link-active', !!on); + }; + + // Releasing Ctrl but Cmd still held + const mockKeyup = { key: 'Control', ctrlKey: false, metaKey: true }; + if (mockKeyup.key === 'Control' || mockKeyup.key === 'Meta') { + setModLinkActive(mockKeyup.ctrlKey || mockKeyup.metaKey); + } + + expect(mockBody.classList.toggle).toHaveBeenCalledWith('mod-link-active', true); + }); + + it('syncs mod-link-active from pointermove events', () => { + const mockBody = { classList: { toggle: vi.fn() } }; + const setModLinkActive = (on: boolean) => { + mockBody.classList.toggle('mod-link-active', !!on); + }; + + // Simulate pointermove with modifier held + const mockPointerMove = { ctrlKey: true, metaKey: false }; + setModLinkActive(mockPointerMove.ctrlKey || mockPointerMove.metaKey); + + expect(mockBody.classList.toggle).toHaveBeenCalledWith('mod-link-active', true); + }); + + it('syncs mod-link-active from pointerover events', () => { + const mockBody = { classList: { toggle: vi.fn() } }; + const setModLinkActive = (on: boolean) => { + mockBody.classList.toggle('mod-link-active', !!on); + }; + + const mockPointerOver = { ctrlKey: false, metaKey: true }; + setModLinkActive(mockPointerOver.ctrlKey || mockPointerOver.metaKey); + + expect(mockBody.classList.toggle).toHaveBeenCalledWith('mod-link-active', true); + }); + + it('clears mod-link-active on window blur', () => { + const mockBody = { classList: { toggle: vi.fn() } }; + const setModLinkActive = (on: boolean) => { + mockBody.classList.toggle('mod-link-active', !!on); + }; + + // Simulate blur event + setModLinkActive(false); + + expect(mockBody.classList.toggle).toHaveBeenCalledWith('mod-link-active', false); }); }); From f4ea86648e040da8543e2782e49ca836e23d2a32 Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Thu, 30 Apr 2026 18:57:02 +0200 Subject: [PATCH 07/13] refactor test use real ansi parser --- src/ansiParser.ts | 439 +++++++++++++++++++++++++++++++++++++ src/test/ansiColor.test.ts | 426 +---------------------------------- src/webviewPanel.ts | 4 +- 3 files changed, 451 insertions(+), 418 deletions(-) create mode 100644 src/ansiParser.ts diff --git a/src/ansiParser.ts b/src/ansiParser.ts new file mode 100644 index 0000000..ffd70b3 --- /dev/null +++ b/src/ansiParser.ts @@ -0,0 +1,439 @@ +/** + * ANSI escape sequence parser and state management. + * + * This module contains the core ANSI SGR (Select Graphic Rendition) logic + * for parsing ANSI escape sequences and managing color/state information. + * It is used by both the webview (for rendering) and tests (for verification). + */ + +// ESC character used in ANSI escape sequences +export const ESC = '\x1b'; + +/** + * ANSI color state interface + */ +export interface AnsiState { + bold: boolean; + italic: boolean; + underline: boolean; + strikethrough: boolean; + blink: boolean; + fastBlink: boolean; + hidden: boolean; + dim: boolean; + reverse: boolean; + fg: string | null; + bg: string | null; + fgRgb: string | null; + bgRgb: string | null; +} + +/** + * Standard 256-color palette (indices 0-255) + * Note: Index 12 (bright blue) is intentionally set to rgb(99,153,255) + * to match the existing .ansi-fg-blue CSS class rather than the xterm standard. + */ +export const ANSI_256: string[] = (() => { + const t: string[] = []; + // 0-7: standard colors + t[0] = 'rgb(0,0,0)'; + t[1] = 'rgb(128,0,0)'; + t[2] = 'rgb(0,128,0)'; + t[3] = 'rgb(128,128,0)'; + t[4] = 'rgb(0,0,128)'; + t[5] = 'rgb(128,0,128)'; + t[6] = 'rgb(0,128,128)'; + t[7] = 'rgb(192,192,192)'; + // 8-15: bright colors + t[8] = 'rgb(128,128,128)'; + t[9] = 'rgb(255,0,0)'; + t[10] = 'rgb(0,255,0)'; + t[11] = 'rgb(255,255,0)'; + t[12] = 'rgb(99,153,255)'; + t[13] = 'rgb(255,0,255)'; + t[14] = 'rgb(0,255,255)'; + t[15] = 'rgb(255,255,255)'; + // 16-231: 6x6x6 color cube + for (let i = 0; i < 216; i++) { + const r = Math.floor(i / 36); + const g = Math.floor((i % 36) / 6); + const b = i % 6; + t[16 + i] = + 'rgb(' + + (r ? r * 40 + 55 : 0) + + ',' + + (g ? g * 40 + 55 : 0) + + ',' + + (b ? b * 40 + 55 : 0) + + ')'; + } + // 232-255: grayscale ramp + for (let i = 0; i < 24; i++) { + const v = i * 10 + 8; + t[232 + i] = 'rgb(' + v + ',' + v + ',' + v + ')'; + } + return t; +})(); + +/** + * Reset state to default values + */ +export function resetAnsiState(state: AnsiState): void { + state.bold = false; + state.dim = false; + state.italic = false; + state.underline = false; + state.strikethrough = false; + state.blink = false; + state.fastBlink = false; + state.hidden = false; + state.reverse = false; + state.fg = null; + state.bg = null; + state.fgRgb = null; + state.bgRgb = null; +} + +/** + * Serialize current state back to SGR escape sequence + */ +export function ansiStateToSgr(state: AnsiState): string { + const codes: number[] = []; + if (state.bold) { codes.push(1); } + if (state.dim) { codes.push(2); } + if (state.italic) { codes.push(3); } + if (state.underline) { codes.push(4); } + if (state.blink) { codes.push(5); } + if (state.fastBlink) { codes.push(6); } + if (state.reverse) { codes.push(7); } + if (state.hidden) { codes.push(8); } + if (state.strikethrough) { codes.push(9); } + + const fgMap: Record = { + black: 30, + red: 31, + green: 32, + yellow: 33, + blue: 34, + magenta: 35, + cyan: 36, + white: 37, + }; + const bgMap: Record = { + black: 40, + red: 41, + green: 42, + yellow: 43, + blue: 44, + magenta: 45, + cyan: 46, + white: 47, + }; + + if (state.fgRgb) { + const mfg = /rgb\((\d+),(\d+),(\d+)\)/.exec(state.fgRgb); + if (mfg) { + codes.push(38, 2, +mfg[1], +mfg[2], +mfg[3]); + } + } else if (state.fg && fgMap[state.fg] !== undefined) { + codes.push(fgMap[state.fg]); + } + + if (state.bgRgb) { + const mbg = /rgb\((\d+),(\d+),(\d+)\)/.exec(state.bgRgb); + if (mbg) { + codes.push(48, 2, +mbg[1], +mbg[2], +mbg[3]); + } + } else if (state.bg && bgMap[state.bg] !== undefined) { + codes.push(bgMap[state.bg]); + } + + if (codes.length === 0) { return ''; } + return ESC + '[' + codes.join(';') + 'm'; +} + +/** + * Process an array of SGR codes + */ +export function ansiApplyCodes(state: AnsiState, codes: number[]): void { + for (let ci = 0; ci < codes.length; ci++) { + const code = codes[ci]; + + // Extended foreground: 38;5;n or 38;2;r;g;b + if (code === 38 && ci + 1 < codes.length) { + if (codes[ci + 1] === 5) { + if (ci + 2 < codes.length) { + const idx = codes[ci + 2]; + if (idx >= 0 && idx <= 255 && ANSI_256[idx]) { + state.fg = null; + state.fgRgb = ANSI_256[idx]; + } + ci += 2; + } else { + ci += 1; + } + continue; + } + if (codes[ci + 1] === 2) { + if (ci + 4 < codes.length) { + state.fg = null; + const r = Math.max(0, Math.min(255, codes[ci + 2])); + const g = Math.max(0, Math.min(255, codes[ci + 3])); + const b = Math.max(0, Math.min(255, codes[ci + 4])); + state.fgRgb = 'rgb(' + r + ',' + g + ',' + b + ')'; + ci += 4; + } else { + ci = codes.length - 1; + } + continue; + } + } + + // Extended background: 48;5;n or 48;2;r;g;b + if (code === 48 && ci + 1 < codes.length) { + if (codes[ci + 1] === 5) { + if (ci + 2 < codes.length) { + const idx = codes[ci + 2]; + if (idx >= 0 && idx <= 255 && ANSI_256[idx]) { + state.bg = null; + state.bgRgb = ANSI_256[idx]; + } + ci += 2; + } else { + ci += 1; + } + continue; + } + if (codes[ci + 1] === 2) { + if (ci + 4 < codes.length) { + state.bg = null; + const r = Math.max(0, Math.min(255, codes[ci + 2])); + const g = Math.max(0, Math.min(255, codes[ci + 3])); + const b = Math.max(0, Math.min(255, codes[ci + 4])); + state.bgRgb = 'rgb(' + r + ',' + g + ',' + b + ')'; + ci += 4; + } else { + ci = codes.length - 1; + } + continue; + } + } + + switch (code) { + case 0: + resetAnsiState(state); + break; + case 1: + state.bold = true; + break; + case 2: + state.dim = true; + break; + case 3: + state.italic = true; + break; + case 4: + state.underline = true; + break; + case 5: + state.blink = true; + state.fastBlink = false; + break; + case 6: + state.fastBlink = true; + state.blink = false; + break; + case 7: + state.reverse = true; + break; + case 8: + state.hidden = true; + break; + case 9: + state.strikethrough = true; + break; + case 22: + state.bold = false; + state.dim = false; + break; + case 23: + state.italic = false; + break; + case 24: + state.underline = false; + break; + case 25: + state.blink = false; + state.fastBlink = false; + break; + case 27: + state.reverse = false; + break; + case 28: + state.hidden = false; + break; + case 29: + state.strikethrough = false; + break; + case 30: + state.fg = 'black'; + state.fgRgb = null; + break; + case 31: + state.fg = 'red'; + state.fgRgb = null; + break; + case 32: + state.fg = 'green'; + state.fgRgb = null; + break; + case 33: + state.fg = 'yellow'; + state.fgRgb = null; + break; + case 34: + state.fg = 'blue'; + state.fgRgb = null; + break; + case 35: + state.fg = 'magenta'; + state.fgRgb = null; + break; + case 36: + state.fg = 'cyan'; + state.fgRgb = null; + break; + case 37: + state.fg = 'white'; + state.fgRgb = null; + break; + case 39: + state.fg = null; + state.fgRgb = null; + break; + case 40: + state.bg = 'black'; + state.bgRgb = null; + break; + case 41: + state.bg = 'red'; + state.bgRgb = null; + break; + case 42: + state.bg = 'green'; + state.bgRgb = null; + break; + case 43: + state.bg = 'yellow'; + state.bgRgb = null; + break; + case 44: + state.bg = 'blue'; + state.bgRgb = null; + break; + case 45: + state.bg = 'magenta'; + state.bgRgb = null; + break; + case 46: + state.bg = 'cyan'; + state.bgRgb = null; + break; + case 47: + state.bg = 'white'; + state.bgRgb = null; + break; + case 49: + state.bg = null; + state.bgRgb = null; + break; + // Bright foreground colors (90-97) + case 90: + state.fg = null; + state.fgRgb = ANSI_256[8]; + break; + case 91: + state.fg = null; + state.fgRgb = ANSI_256[9]; + break; + case 92: + state.fg = null; + state.fgRgb = ANSI_256[10]; + break; + case 93: + state.fg = null; + state.fgRgb = ANSI_256[11]; + break; + case 94: + state.fg = null; + state.fgRgb = ANSI_256[12]; + break; + case 95: + state.fg = null; + state.fgRgb = ANSI_256[13]; + break; + case 96: + state.fg = null; + state.fgRgb = ANSI_256[14]; + break; + case 97: + state.fg = null; + state.fgRgb = ANSI_256[15]; + break; + // Bright background colors (100-107) + case 100: + state.bg = null; + state.bgRgb = ANSI_256[8]; + break; + case 101: + state.bg = null; + state.bgRgb = ANSI_256[9]; + break; + case 102: + state.bg = null; + state.bgRgb = ANSI_256[10]; + break; + case 103: + state.bg = null; + state.bgRgb = ANSI_256[11]; + break; + case 104: + state.bg = null; + state.bgRgb = ANSI_256[12]; + break; + case 105: + state.bg = null; + state.bgRgb = ANSI_256[13]; + break; + case 106: + state.bg = null; + state.bgRgb = ANSI_256[14]; + break; + case 107: + state.bg = null; + state.bgRgb = ANSI_256[15]; + break; + } + } +} + +/** + * Create a fresh ANSI state object + */ +export function createAnsiState(): AnsiState { + return { + bold: false, + dim: false, + italic: false, + underline: false, + strikethrough: false, + blink: false, + fastBlink: false, + hidden: false, + reverse: false, + fg: null, + bg: null, + fgRgb: null, + bgRgb: null, + }; +} diff --git a/src/test/ansiColor.test.ts b/src/test/ansiColor.test.ts index 788160d..45c24b6 100644 --- a/src/test/ansiColor.test.ts +++ b/src/test/ansiColor.test.ts @@ -12,424 +12,16 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; +import { + type AnsiState, + ANSI_256, + resetAnsiState, + ansiStateToSgr, + ansiApplyCodes, + createAnsiState, +} from '../ansiParser'; + -// ESC character used in ANSI escape sequences -const ESC = '\x1b'; - -// ANSI color state interface matching the implementation -interface AnsiState { - bold: boolean; - italic: boolean; - underline: boolean; - strikethrough: boolean; - blink: boolean; - fastBlink: boolean; - hidden: boolean; - dim: boolean; - reverse: boolean; - fg: string | null; - bg: string | null; - fgRgb: string | null; - bgRgb: string | null; -} - -// Standard 256-color palette (indices 0-255) - matches webviewPanel.ts -const ANSI_256: string[] = (() => { - const t: string[] = []; - // 0-7: standard colors - t[0] = 'rgb(0,0,0)'; - t[1] = 'rgb(128,0,0)'; - t[2] = 'rgb(0,128,0)'; - t[3] = 'rgb(128,128,0)'; - t[4] = 'rgb(0,0,128)'; - t[5] = 'rgb(128,0,128)'; - t[6] = 'rgb(0,128,128)'; - t[7] = 'rgb(192,192,192)'; - // 8-15: bright colors - t[8] = 'rgb(128,128,128)'; - t[9] = 'rgb(255,0,0)'; - t[10] = 'rgb(0,255,0)'; - t[11] = 'rgb(255,255,0)'; - t[12] = 'rgb(99,153,255)'; - t[13] = 'rgb(255,0,255)'; - t[14] = 'rgb(0,255,255)'; - t[15] = 'rgb(255,255,255)'; - // 16-231: 6x6x6 color cube - for (let i = 0; i < 216; i++) { - const r = Math.floor(i / 36); - const g = Math.floor((i % 36) / 6); - const b = i % 6; - t[16 + i] = - 'rgb(' + - (r ? r * 40 + 55 : 0) + - ',' + - (g ? g * 40 + 55 : 0) + - ',' + - (b ? b * 40 + 55 : 0) + - ')'; - } - // 232-255: grayscale ramp - for (let i = 0; i < 24; i++) { - const v = i * 10 + 8; - t[232 + i] = 'rgb(' + v + ',' + v + ',' + v + ')'; - } - return t; -})(); - -// Reset state to default values -function resetAnsiState(state: AnsiState): void { - state.bold = false; - state.dim = false; - state.italic = false; - state.underline = false; - state.strikethrough = false; - state.blink = false; - state.fastBlink = false; - state.hidden = false; - state.reverse = false; - state.fg = null; - state.bg = null; - state.fgRgb = null; - state.bgRgb = null; -} - -// Serialize current state back to SGR escape sequence -function ansiStateToSgr(state: AnsiState): string { - const codes: number[] = []; - if (state.bold) { codes.push(1); } - if (state.dim) { codes.push(2); } - if (state.italic) { codes.push(3); } - if (state.underline) { codes.push(4); } - if (state.blink) { codes.push(5); } - if (state.fastBlink) { codes.push(6); } - if (state.reverse) { codes.push(7); } - if (state.hidden) { codes.push(8); } - if (state.strikethrough) { codes.push(9); } - - const fgMap: Record = { - black: 30, - red: 31, - green: 32, - yellow: 33, - blue: 34, - magenta: 35, - cyan: 36, - white: 37, - }; - const bgMap: Record = { - black: 40, - red: 41, - green: 42, - yellow: 43, - blue: 44, - magenta: 45, - cyan: 46, - white: 47, - }; - - if (state.fgRgb) { - const mfg = /rgb\((\d+),(\d+),(\d+)\)/.exec(state.fgRgb); - if (mfg) { - codes.push(38, 2, +mfg[1], +mfg[2], +mfg[3]); - } - } else if (state.fg && fgMap[state.fg] !== undefined) { - codes.push(fgMap[state.fg]); - } - - if (state.bgRgb) { - const mbg = /rgb\((\d+),(\d+),(\d+)\)/.exec(state.bgRgb); - if (mbg) { - codes.push(48, 2, +mbg[1], +mbg[2], +mbg[3]); - } - } else if (state.bg && bgMap[state.bg] !== undefined) { - codes.push(bgMap[state.bg]); - } - - if (codes.length === 0) { return ''; } - return ESC + '[' + codes.join(';') + 'm'; -} - -// Process an array of SGR codes -function ansiApplyCodes(state: AnsiState, codes: number[]): void { - for (let ci = 0; ci < codes.length; ci++) { - const code = codes[ci]; - - // Extended foreground: 38;5;n or 38;2;r;g;b - if (code === 38 && ci + 1 < codes.length) { - if (codes[ci + 1] === 5) { - if (ci + 2 < codes.length) { - const idx = codes[ci + 2]; - if (idx >= 0 && idx <= 255 && ANSI_256[idx]) { - state.fg = null; - state.fgRgb = ANSI_256[idx]; - } - ci += 2; - } else { - ci += 1; - } - continue; - } - if (codes[ci + 1] === 2) { - if (ci + 4 < codes.length) { - state.fg = null; - const r = Math.max(0, Math.min(255, codes[ci + 2])); - const g = Math.max(0, Math.min(255, codes[ci + 3])); - const b = Math.max(0, Math.min(255, codes[ci + 4])); - state.fgRgb = 'rgb(' + r + ',' + g + ',' + b + ')'; - ci += 4; - } else { - ci = codes.length - 1; - } - continue; - } - } - - // Extended background: 48;5;n or 48;2;r;g;b - if (code === 48 && ci + 1 < codes.length) { - if (codes[ci + 1] === 5) { - if (ci + 2 < codes.length) { - const idx = codes[ci + 2]; - if (idx >= 0 && idx <= 255 && ANSI_256[idx]) { - state.bg = null; - state.bgRgb = ANSI_256[idx]; - } - ci += 2; - } else { - ci += 1; - } - continue; - } - if (codes[ci + 1] === 2) { - if (ci + 4 < codes.length) { - state.bg = null; - const r = Math.max(0, Math.min(255, codes[ci + 2])); - const g = Math.max(0, Math.min(255, codes[ci + 3])); - const b = Math.max(0, Math.min(255, codes[ci + 4])); - state.bgRgb = 'rgb(' + r + ',' + g + ',' + b + ')'; - ci += 4; - } else { - ci = codes.length - 1; - } - continue; - } - } - - switch (code) { - case 0: - resetAnsiState(state); - break; - case 1: - state.bold = true; - break; - case 2: - state.dim = true; - break; - case 3: - state.italic = true; - break; - case 4: - state.underline = true; - break; - case 5: - state.blink = true; - state.fastBlink = false; - break; - case 6: - state.fastBlink = true; - state.blink = false; - break; - case 7: - state.reverse = true; - break; - case 8: - state.hidden = true; - break; - case 9: - state.strikethrough = true; - break; - case 22: - state.bold = false; - state.dim = false; - break; - case 23: - state.italic = false; - break; - case 24: - state.underline = false; - break; - case 25: - state.blink = false; - state.fastBlink = false; - break; - case 27: - state.reverse = false; - break; - case 28: - state.hidden = false; - break; - case 29: - state.strikethrough = false; - break; - case 30: - state.fg = 'black'; - state.fgRgb = null; - break; - case 31: - state.fg = 'red'; - state.fgRgb = null; - break; - case 32: - state.fg = 'green'; - state.fgRgb = null; - break; - case 33: - state.fg = 'yellow'; - state.fgRgb = null; - break; - case 34: - state.fg = 'blue'; - state.fgRgb = null; - break; - case 35: - state.fg = 'magenta'; - state.fgRgb = null; - break; - case 36: - state.fg = 'cyan'; - state.fgRgb = null; - break; - case 37: - state.fg = 'white'; - state.fgRgb = null; - break; - case 39: - state.fg = null; - state.fgRgb = null; - break; - case 40: - state.bg = 'black'; - state.bgRgb = null; - break; - case 41: - state.bg = 'red'; - state.bgRgb = null; - break; - case 42: - state.bg = 'green'; - state.bgRgb = null; - break; - case 43: - state.bg = 'yellow'; - state.bgRgb = null; - break; - case 44: - state.bg = 'blue'; - state.bgRgb = null; - break; - case 45: - state.bg = 'magenta'; - state.bgRgb = null; - break; - case 46: - state.bg = 'cyan'; - state.bgRgb = null; - break; - case 47: - state.bg = 'white'; - state.bgRgb = null; - break; - case 49: - state.bg = null; - state.bgRgb = null; - break; - // Bright foreground colors (90-97) - case 90: - state.fg = null; - state.fgRgb = ANSI_256[8]; - break; - case 91: - state.fg = null; - state.fgRgb = ANSI_256[9]; - break; - case 92: - state.fg = null; - state.fgRgb = ANSI_256[10]; - break; - case 93: - state.fg = null; - state.fgRgb = ANSI_256[11]; - break; - case 94: - state.fg = null; - state.fgRgb = ANSI_256[12]; - break; - case 95: - state.fg = null; - state.fgRgb = ANSI_256[13]; - break; - case 96: - state.fg = null; - state.fgRgb = ANSI_256[14]; - break; - case 97: - state.fg = null; - state.fgRgb = ANSI_256[15]; - break; - // Bright background colors (100-107) - case 100: - state.bg = null; - state.bgRgb = ANSI_256[8]; - break; - case 101: - state.bg = null; - state.bgRgb = ANSI_256[9]; - break; - case 102: - state.bg = null; - state.bgRgb = ANSI_256[10]; - break; - case 103: - state.bg = null; - state.bgRgb = ANSI_256[11]; - break; - case 104: - state.bg = null; - state.bgRgb = ANSI_256[12]; - break; - case 105: - state.bg = null; - state.bgRgb = ANSI_256[13]; - break; - case 106: - state.bg = null; - state.bgRgb = ANSI_256[14]; - break; - case 107: - state.bg = null; - state.bgRgb = ANSI_256[15]; - break; - } - } -} - -// Create a fresh ANSI state object -function createAnsiState(): AnsiState { - return { - bold: false, - dim: false, - italic: false, - underline: false, - strikethrough: false, - blink: false, - fastBlink: false, - hidden: false, - reverse: false, - fg: null, - bg: null, - fgRgb: null, - bgRgb: null, - }; -} describe('ANSI Color Support', () => { let state: AnsiState; diff --git a/src/webviewPanel.ts b/src/webviewPanel.ts index 955dd6b..67a66bf 100644 --- a/src/webviewPanel.ts +++ b/src/webviewPanel.ts @@ -1523,7 +1523,7 @@ export class EspDecoderWebviewPanel implements vscode.WebviewViewProvider { .ansi-fg-blue { color: rgb( 99,153,255); } .ansi-fg-magenta { color: rgb(255, 0,255); } .ansi-fg-cyan { color: rgb( 0,255,255); } - .ansi-fg-white { color: rgb(187,187,187); } + .ansi-fg-white { color: rgb(192,192,192); } .ansi-bg-black { background-color: rgb( 0, 0, 0); } .ansi-bg-red { background-color: rgb(255, 0, 0); } .ansi-bg-green { background-color: rgb( 0,255, 0); } @@ -1859,6 +1859,8 @@ export class EspDecoderWebviewPanel implements vscode.WebviewViewProvider { const LINE_SPLIT_RE = new RegExp('(' + CRLF + '|' + CR + '|' + LF + ')'); // Standard 256-color palette (indices 0-255) + // NOTE: This JavaScript logic should be kept in sync with src/ansiParser.ts + // to ensure consistency between the webview (browser) and test implementations. var ANSI_256 = (function () { var t = []; // 0-7: standard colors From 8319cda0ba7c35e035388bdee94c50d372a29ec4 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:14:44 +0200 Subject: [PATCH 08/13] =?UTF-8?q?=F0=9F=93=9D=20CodeRabbit=20Chat:=20Imple?= =?UTF-8?q?ment=20requested=20code=20changes=20(#46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/ansiParser.ts | 181 +++++++++++++++++++++++++++++++++++++++++++ src/webviewPanel.ts | 185 +------------------------------------------- 2 files changed, 184 insertions(+), 182 deletions(-) diff --git a/src/ansiParser.ts b/src/ansiParser.ts index ffd70b3..948b973 100644 --- a/src/ansiParser.ts +++ b/src/ansiParser.ts @@ -417,6 +417,187 @@ export function ansiApplyCodes(state: AnsiState, codes: number[]): void { } } +/** + * Generate the JavaScript code block for ANSI colour support to be embedded + * in the webview