diff --git a/.gitignore b/.gitignore index 083034e4..10121313 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ node_modules dist/ build/ out/ +coverage/ diff --git a/jest.config.js b/jest.config.js index e9553a40..7c776c8c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -44,6 +44,12 @@ export default { ...defaultConfig, displayName: 'lana', rootDir: '/lana', + setupFilesAfterEnv: ['/src/__tests__/setup.ts'], + moduleNameMapper: { + ...defaultConfig.moduleNameMapper, + '^vscode$': '/src/__tests__/mocks/vscode.ts', + '^apex-log-parser$': '/../apex-log-parser/src/index.ts', + }, transformIgnorePatterns: [ // allow lit/@lit transformation '/node_modules/(?!@?lit)', diff --git a/lana/src/__tests__/helpers/test-builders.ts b/lana/src/__tests__/helpers/test-builders.ts new file mode 100644 index 00000000..43f78088 --- /dev/null +++ b/lana/src/__tests__/helpers/test-builders.ts @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * Factory functions for building test data in lana tests. + */ + +import type { ApexLog, LogEvent } from 'apex-log-parser'; + +import { createMockExtensionContext, type MockExtensionContext } from '../mocks/vscode.js'; + +/** + * Partial type for creating mock LogEvent objects. + * Only requires the fields you want to set, everything else gets defaults. + */ +type PartialLogEvent = Partial<{ + type: string | null; + text: string; + timestamp: number; + exitStamp: number | null; + children: LogEvent[]; + parent: LogEvent | null; + duration: { self: number; total: number }; + soqlCount: { self: number; total: number }; + soqlRowCount: { self: number; total: number }; + dmlCount: { self: number; total: number }; + dmlRowCount: { self: number; total: number }; + totalThrownCount: number; + lineNumber: number | 'EXTERNAL' | null; + namespace: string; + logLine: string; + isExit: boolean; + isParent: boolean; + isTruncated: boolean; +}>; + +/** + * Creates a mock LogEvent with sensible defaults. + * All properties are optional - specify only what you need for the test. + */ +export function createMockLogEvent(overrides: PartialLogEvent = {}): LogEvent { + const base = { + logParser: {} as unknown, + parent: null, + children: [], + type: 'METHOD_ENTRY' as const, + logLine: '', + text: 'Test Event', + acceptsText: false, + isExit: false, + isParent: false, + isTruncated: false, + nextLineIsExit: false, + lineNumber: null, + namespace: 'default', + hasValidSymbols: false, + suffix: null, + discontinuity: false, + timestamp: 1000000, + exitStamp: 2000000, + subCategory: 'Method' as const, + cpuType: 'method' as const, + duration: { self: 1000000, total: 1000000 }, + dmlRowCount: { self: 0, total: 0 }, + soqlRowCount: { self: 0, total: 0 }, + soslRowCount: { self: 0, total: 0 }, + dmlCount: { self: 0, total: 0 }, + soqlCount: { self: 0, total: 0 }, + soslCount: { self: 0, total: 0 }, + totalThrownCount: 0, + exitTypes: [], + recalculateDurations: jest.fn(), + }; + + return { ...base, ...overrides } as unknown as LogEvent; +} + +/** + * Partial type for creating mock ApexLog objects. + */ +type PartialApexLog = Partial<{ + children: LogEvent[]; + timestamp: number; + exitStamp: number; + size: number; + namespaces: string[]; + duration: { self: number; total: number }; +}>; + +/** + * Creates a mock ApexLog with sensible defaults. + * Useful for testing components that work with parsed log data. + */ +export function createMockApexLog(overrides: PartialApexLog = {}): ApexLog { + const base = { + logParser: {} as unknown, + parent: null, + children: [], + type: null, + logLine: '', + text: 'LOG_ROOT', + acceptsText: false, + isExit: false, + isParent: false, + isTruncated: false, + nextLineIsExit: false, + lineNumber: null, + namespace: '', + hasValidSymbols: false, + suffix: null, + discontinuity: false, + timestamp: 0, + exitStamp: 0, + subCategory: '' as const, + cpuType: '' as const, + duration: { self: 0, total: 0 }, + dmlRowCount: { self: 0, total: 0 }, + soqlRowCount: { self: 0, total: 0 }, + soslRowCount: { self: 0, total: 0 }, + dmlCount: { self: 0, total: 0 }, + soqlCount: { self: 0, total: 0 }, + soslCount: { self: 0, total: 0 }, + totalThrownCount: 0, + exitTypes: [], + recalculateDurations: jest.fn(), + setTimes: jest.fn(), + size: 0, + debugLevels: [], + namespaces: [], + logIssues: [], + parsingErrors: [], + governorLimits: { + soqlQueries: { used: 0, limit: 0 }, + soslQueries: { used: 0, limit: 0 }, + queryRows: { used: 0, limit: 0 }, + dmlStatements: { used: 0, limit: 0 }, + publishImmediateDml: { used: 0, limit: 0 }, + dmlRows: { used: 0, limit: 0 }, + cpuTime: { used: 0, limit: 0 }, + heapSize: { used: 0, limit: 0 }, + callouts: { used: 0, limit: 0 }, + emailInvocations: { used: 0, limit: 0 }, + futureCalls: { used: 0, limit: 0 }, + queueableJobsAddedToQueue: { used: 0, limit: 0 }, + mobileApexPushCalls: { used: 0, limit: 0 }, + byNamespace: new Map(), + snapshots: [], + }, + executionEndTime: 0, + }; + + return { ...base, ...overrides } as unknown as ApexLog; +} + +/** + * Mock Display object for Context. + */ +export interface MockDisplay { + output: jest.Mock; + showErrorMessage: jest.Mock; + showInformationMessage: jest.Mock; + showWarningMessage: jest.Mock; +} + +export function createMockDisplay(): MockDisplay { + return { + output: jest.fn(), + showErrorMessage: jest.fn(), + showInformationMessage: jest.fn(), + showWarningMessage: jest.fn(), + }; +} + +/** + * Mock Context for testing command handlers and features. + */ +export interface MockContext { + context: MockExtensionContext; + display: MockDisplay; + workspaces: { uri: { fsPath: string }; name: string }[]; + symbolFinder: { findSymbol: jest.Mock }; + findSymbol: jest.Mock; +} + +/** + * Creates a mock Context object for testing. + * Includes mocked ExtensionContext, Display, and symbolFinder. + */ +export function createMockContext(overrides: Partial = {}): MockContext { + const display = createMockDisplay(); + const context = createMockExtensionContext(); + + const base: MockContext = { + context, + display, + workspaces: [], + symbolFinder: { findSymbol: jest.fn().mockResolvedValue([]) }, + findSymbol: jest.fn().mockResolvedValue([]), + }; + + return { ...base, ...overrides }; +} + +/** + * Creates a simple event tree for testing hierarchical event searches. + * Returns parent with nested children at specified depths. + */ +export function createMockEventTree(config: { + rootTimestamp: number; + rootExitStamp: number; + childConfigs?: Array<{ + timestamp: number; + exitStamp: number; + children?: Array<{ timestamp: number; exitStamp: number }>; + }>; +}): LogEvent { + const root = createMockLogEvent({ + timestamp: config.rootTimestamp, + exitStamp: config.rootExitStamp, + children: [], + }); + + if (config.childConfigs) { + root.children = config.childConfigs.map((childConfig) => { + const child = createMockLogEvent({ + timestamp: childConfig.timestamp, + exitStamp: childConfig.exitStamp, + parent: root, + children: [], + }); + + if (childConfig.children) { + child.children = childConfig.children.map((grandchildConfig) => + createMockLogEvent({ + timestamp: grandchildConfig.timestamp, + exitStamp: grandchildConfig.exitStamp, + parent: child, + }), + ); + } + + return child; + }); + } + + return root; +} diff --git a/lana/src/__tests__/log-utils.test.ts b/lana/src/__tests__/log-utils.test.ts new file mode 100644 index 00000000..134e0ed0 --- /dev/null +++ b/lana/src/__tests__/log-utils.test.ts @@ -0,0 +1,382 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +import { buildMetricParts, formatDuration, TIMESTAMP_REGEX } from '../log-utils.js'; +import { createMockLogEvent } from './helpers/test-builders.js'; + +describe('log-utils', () => { + describe('formatDuration', () => { + describe('milliseconds (< 1 second)', () => { + it('should format nanoseconds as milliseconds for small values', () => { + expect(formatDuration(1_000_000)).toBe('1.00ms'); + }); + + it('should format sub-millisecond values', () => { + expect(formatDuration(500_000)).toBe('0.50ms'); + }); + + it('should format zero duration', () => { + expect(formatDuration(0)).toBe('0.00ms'); + }); + + it('should format values just under 1 second', () => { + expect(formatDuration(999_000_000)).toBe('999.00ms'); + }); + + it('should format with 2 decimal places', () => { + expect(formatDuration(123_456_789)).toBe('123.46ms'); + }); + }); + + describe('seconds (1-60 seconds)', () => { + it('should format exactly 1 second', () => { + expect(formatDuration(1_000_000_000)).toBe('1.00s'); + }); + + it('should format seconds with decimals', () => { + expect(formatDuration(1_500_000_000)).toBe('1.50s'); + }); + + it('should format values just under 60 seconds', () => { + expect(formatDuration(59_990_000_000)).toBe('59.99s'); + }); + + it('should format 30 seconds', () => { + expect(formatDuration(30_000_000_000)).toBe('30.00s'); + }); + }); + + describe('minutes (>= 60 seconds)', () => { + it('should format exactly 1 minute', () => { + expect(formatDuration(60_000_000_000)).toBe('1m 0.00s'); + }); + + it('should format 1 minute and 30 seconds', () => { + expect(formatDuration(90_000_000_000)).toBe('1m 30.00s'); + }); + + it('should format multiple minutes', () => { + expect(formatDuration(150_000_000_000)).toBe('2m 30.00s'); + }); + + it('should format large duration', () => { + expect(formatDuration(600_000_000_000)).toBe('10m 0.00s'); + }); + + it('should format minutes with fractional seconds', () => { + expect(formatDuration(61_234_567_890)).toBe('1m 1.23s'); + }); + }); + }); + + describe('TIMESTAMP_REGEX', () => { + describe('valid timestamps', () => { + it('should match standard timestamp format', () => { + const line = '09:45:31.888 (38889007737)|METHOD_ENTRY'; + const match = line.match(TIMESTAMP_REGEX); + + expect(match).not.toBeNull(); + expect(match?.[1]).toBe('38889007737'); + }); + + it('should match timestamp at start of log line', () => { + const line = '12:00:00.000 (1000)|CODE_UNIT_STARTED'; + const match = line.match(TIMESTAMP_REGEX); + + expect(match).not.toBeNull(); + expect(match?.[1]).toBe('1000'); + }); + + it('should match timestamp with long nanoseconds', () => { + const line = '23:59:59.999 (999999999999)|SOQL_EXECUTE_BEGIN'; + const match = line.match(TIMESTAMP_REGEX); + + expect(match).not.toBeNull(); + expect(match?.[1]).toBe('999999999999'); + }); + + it('should match timestamp with short nanoseconds', () => { + const line = '00:00:00.001 (1)|DML_BEGIN'; + const match = line.match(TIMESTAMP_REGEX); + + expect(match).not.toBeNull(); + expect(match?.[1]).toBe('1'); + }); + + it('should match timestamp with varying decimal precision', () => { + const line = '10:30:45.1 (12345)|EXECUTION_STARTED'; + const match = line.match(TIMESTAMP_REGEX); + + expect(match).not.toBeNull(); + expect(match?.[1]).toBe('12345'); + }); + + it('should match timestamp with space before parentheses', () => { + const line = '09:45:31.888 (38889007737)|METHOD_ENTRY'; + const match = line.match(TIMESTAMP_REGEX); + + expect(match).not.toBeNull(); + }); + }); + + describe('invalid timestamps', () => { + it('should not match line without timestamp', () => { + const line = 'This is just some text'; + const match = line.match(TIMESTAMP_REGEX); + + expect(match).toBeNull(); + }); + + it('should not match malformed time', () => { + const line = '9:45:31.888 (38889007737)|METHOD_ENTRY'; + const match = line.match(TIMESTAMP_REGEX); + + expect(match).toBeNull(); + }); + + it('should not match timestamp without pipe', () => { + const line = '09:45:31.888 (38889007737) METHOD_ENTRY'; + const match = line.match(TIMESTAMP_REGEX); + + expect(match).toBeNull(); + }); + + it('should not match timestamp in middle of line', () => { + const line = 'prefix 09:45:31.888 (38889007737)|METHOD_ENTRY'; + const match = line.match(TIMESTAMP_REGEX); + + expect(match).toBeNull(); + }); + + it('should not match empty string', () => { + const line = ''; + const match = line.match(TIMESTAMP_REGEX); + + expect(match).toBeNull(); + }); + }); + }); + + describe('buildMetricParts', () => { + describe('duration formatting', () => { + it('should include total duration when self equals total', () => { + const event = createMockLogEvent({ + duration: { self: 1_000_000_000, total: 1_000_000_000 }, + }); + + const parts = buildMetricParts(event); + + expect(parts[0]).toBe('**1.00s**'); + }); + + it('should include self time when different from total', () => { + const event = createMockLogEvent({ + duration: { self: 500_000_000, total: 1_000_000_000 }, + }); + + const parts = buildMetricParts(event); + + expect(parts[0]).toBe('**1.00s** (self: 500.00ms)'); + }); + + it('should format zero duration', () => { + const event = createMockLogEvent({ + duration: { self: 0, total: 0 }, + }); + + const parts = buildMetricParts(event); + + expect(parts[0]).toBe('**0.00ms**'); + }); + }); + + describe('SOQL metrics', () => { + it('should include SOQL count when present', () => { + const event = createMockLogEvent({ + duration: { self: 0, total: 0 }, + soqlCount: { self: 0, total: 5 }, + }); + + const parts = buildMetricParts(event); + + expect(parts).toContain('5 SOQL'); + }); + + it('should include SOQL self count when non-zero', () => { + const event = createMockLogEvent({ + duration: { self: 0, total: 0 }, + soqlCount: { self: 2, total: 5 }, + }); + + const parts = buildMetricParts(event); + + expect(parts).toContain('5 SOQL (self: 2)'); + }); + + it('should not include SOQL when count is zero', () => { + const event = createMockLogEvent({ + duration: { self: 0, total: 0 }, + soqlCount: { self: 0, total: 0 }, + }); + + const parts = buildMetricParts(event); + + expect(parts.some((p) => p.includes('SOQL'))).toBe(false); + }); + + it('should include SOQL row count when present', () => { + const event = createMockLogEvent({ + duration: { self: 0, total: 0 }, + soqlRowCount: { self: 0, total: 100 }, + }); + + const parts = buildMetricParts(event); + + expect(parts).toContain('100 rows'); + }); + + it('should not include SOQL rows when zero', () => { + const event = createMockLogEvent({ + duration: { self: 0, total: 0 }, + soqlRowCount: { self: 0, total: 0 }, + }); + + const parts = buildMetricParts(event); + + expect(parts.some((p) => p.includes('rows'))).toBe(false); + }); + }); + + describe('DML metrics', () => { + it('should include DML count when present', () => { + const event = createMockLogEvent({ + duration: { self: 0, total: 0 }, + dmlCount: { self: 0, total: 3 }, + }); + + const parts = buildMetricParts(event); + + expect(parts).toContain('3 DML'); + }); + + it('should include DML self count when non-zero', () => { + const event = createMockLogEvent({ + duration: { self: 0, total: 0 }, + dmlCount: { self: 1, total: 3 }, + }); + + const parts = buildMetricParts(event); + + expect(parts).toContain('3 DML (self: 1)'); + }); + + it('should not include DML when count is zero', () => { + const event = createMockLogEvent({ + duration: { self: 0, total: 0 }, + dmlCount: { self: 0, total: 0 }, + }); + + const parts = buildMetricParts(event); + + expect(parts.some((p) => p.includes('DML'))).toBe(false); + }); + + it('should include DML row count when present', () => { + const event = createMockLogEvent({ + duration: { self: 0, total: 0 }, + dmlRowCount: { self: 0, total: 50 }, + }); + + const parts = buildMetricParts(event); + + expect(parts).toContain('50 DML rows'); + }); + + it('should not include DML rows when zero', () => { + const event = createMockLogEvent({ + duration: { self: 0, total: 0 }, + dmlRowCount: { self: 0, total: 0 }, + }); + + const parts = buildMetricParts(event); + + expect(parts.some((p) => p.includes('DML rows'))).toBe(false); + }); + }); + + describe('exception metrics', () => { + it('should include thrown count when present', () => { + const event = createMockLogEvent({ + duration: { self: 0, total: 0 }, + totalThrownCount: 2, + }); + + const parts = buildMetricParts(event); + + expect(parts.some((p) => p.includes('2 thrown'))).toBe(true); + }); + + it('should not include thrown when zero', () => { + const event = createMockLogEvent({ + duration: { self: 0, total: 0 }, + totalThrownCount: 0, + }); + + const parts = buildMetricParts(event); + + expect(parts.some((p) => p.includes('thrown'))).toBe(false); + }); + + it('should include warning emoji for exceptions', () => { + const event = createMockLogEvent({ + duration: { self: 0, total: 0 }, + totalThrownCount: 1, + }); + + const parts = buildMetricParts(event); + + expect(parts.some((p) => p.includes('\u26a0\ufe0f'))).toBe(true); + }); + }); + + describe('combined metrics', () => { + it('should include all metrics in correct order', () => { + const event = createMockLogEvent({ + duration: { self: 500_000_000, total: 1_000_000_000 }, + soqlCount: { self: 1, total: 5 }, + soqlRowCount: { self: 0, total: 100 }, + dmlCount: { self: 1, total: 3 }, + dmlRowCount: { self: 0, total: 50 }, + totalThrownCount: 1, + }); + + const parts = buildMetricParts(event); + + expect(parts.length).toBe(6); + expect(parts[0]).toContain('1.00s'); + expect(parts[1]).toContain('SOQL'); + expect(parts[2]).toContain('rows'); + expect(parts[3]).toContain('DML'); + expect(parts[4]).toContain('DML rows'); + expect(parts[5]).toContain('thrown'); + }); + + it('should handle event with only duration', () => { + const event = createMockLogEvent({ + duration: { self: 100_000_000, total: 100_000_000 }, + soqlCount: { self: 0, total: 0 }, + soqlRowCount: { self: 0, total: 0 }, + dmlCount: { self: 0, total: 0 }, + dmlRowCount: { self: 0, total: 0 }, + totalThrownCount: 0, + }); + + const parts = buildMetricParts(event); + + expect(parts.length).toBe(1); + expect(parts[0]).toBe('**100.00ms**'); + }); + }); + }); +}); diff --git a/lana/src/__tests__/mocks/vscode.ts b/lana/src/__tests__/mocks/vscode.ts new file mode 100644 index 00000000..72405309 --- /dev/null +++ b/lana/src/__tests__/mocks/vscode.ts @@ -0,0 +1,497 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * VS Code API mock for Jest unit tests. + * Provides stateful mocks for testing VS Code extension functionality. + */ + +// Track subscriptions for cleanup +const subscriptions: { dispose: jest.Mock }[] = []; + +// Mock Position class +export class Position { + readonly line: number; + readonly character: number; + + constructor(line: number, character: number) { + this.line = line; + this.character = character; + } + + isEqual(other: Position): boolean { + return this.line === other.line && this.character === other.character; + } + + isBefore(other: Position): boolean { + return this.line < other.line || (this.line === other.line && this.character < other.character); + } + + isAfter(other: Position): boolean { + return this.line > other.line || (this.line === other.line && this.character > other.character); + } + + translate(lineDelta: number = 0, characterDelta: number = 0): Position { + return new Position(this.line + lineDelta, this.character + characterDelta); + } + + with(line?: number, character?: number): Position { + return new Position(line ?? this.line, character ?? this.character); + } +} + +// Mock Range class +export class Range { + readonly start: Position; + readonly end: Position; + + constructor(startLine: number, startChar: number, endLine: number, endChar: number); + constructor(start: Position, end: Position); + constructor( + startOrStartLine: number | Position, + startCharOrEnd: number | Position, + endLine?: number, + endChar?: number, + ) { + if (typeof startOrStartLine === 'number') { + this.start = new Position(startOrStartLine, startCharOrEnd as number); + this.end = new Position(endLine!, endChar!); + } else { + this.start = startOrStartLine; + this.end = startCharOrEnd as Position; + } + } + + get isEmpty(): boolean { + return this.start.isEqual(this.end); + } + + get isSingleLine(): boolean { + return this.start.line === this.end.line; + } + + contains(positionOrRange: Position | Range): boolean { + if (positionOrRange instanceof Position) { + return !positionOrRange.isBefore(this.start) && !positionOrRange.isAfter(this.end); + } + return this.contains(positionOrRange.start) && this.contains(positionOrRange.end); + } + + isEqual(other: Range): boolean { + return this.start.isEqual(other.start) && this.end.isEqual(other.end); + } +} + +// Mock Uri class +export const Uri = { + file: jest.fn((path: string) => ({ + scheme: 'file', + authority: '', + path, + fsPath: path, + query: '', + fragment: '', + with: jest.fn(), + toString: jest.fn(() => `file://${path}`), + toJSON: jest.fn(() => ({ scheme: 'file', path, fsPath: path })), + })), + parse: jest.fn((value: string) => ({ + scheme: value.startsWith('file://') ? 'file' : 'unknown', + authority: '', + path: value.replace('file://', ''), + fsPath: value.replace('file://', ''), + query: '', + fragment: '', + with: jest.fn(), + toString: jest.fn(() => value), + })), + joinPath: jest.fn((base, ...pathSegments) => ({ + ...base, + path: [base.path, ...pathSegments].join('/'), + fsPath: [base.fsPath, ...pathSegments].join('/'), + })), +}; + +// Mock FoldingRange class +export class FoldingRange { + readonly start: number; + readonly end: number; + readonly kind?: FoldingRangeKind; + + constructor(start: number, end: number, kind?: FoldingRangeKind) { + this.start = start; + this.end = end; + this.kind = kind; + } +} + +// Mock FoldingRangeKind enum +export const FoldingRangeKind = { + Comment: 1, + Imports: 2, + Region: 3, +} as const; +export type FoldingRangeKind = (typeof FoldingRangeKind)[keyof typeof FoldingRangeKind]; + +// Mock TextLine +export interface MockTextLine { + lineNumber: number; + text: string; + range: Range; + rangeIncludingLineBreak: Range; + firstNonWhitespaceCharacterIndex: number; + isEmptyOrWhitespace: boolean; +} + +const createMockTextLine = (lineNumber: number, text: string): MockTextLine => ({ + lineNumber, + text, + range: new Range(lineNumber, 0, lineNumber, text.length), + rangeIncludingLineBreak: new Range(lineNumber, 0, lineNumber, text.length + 1), + firstNonWhitespaceCharacterIndex: text.search(/\S/), + isEmptyOrWhitespace: text.trim().length === 0, +}); + +// Mock TextDocument +export interface MockTextDocument { + uri: ReturnType; + fileName: string; + languageId: string; + version: number; + isDirty: boolean; + isUntitled: boolean; + isClosed: boolean; + eol: number; + lineCount: number; + getText: jest.Mock; + lineAt: jest.Mock; + positionAt: jest.Mock; + offsetAt: jest.Mock; + getWordRangeAtPosition: jest.Mock; + validatePosition: jest.Mock; + validateRange: jest.Mock; + save: jest.Mock; +} + +export const createMockTextDocument = (options: { + uri?: string; + languageId?: string; + content?: string; + lines?: string[]; +}): MockTextDocument => { + const uri = options.uri || '/test/file.log'; + const lines = options.lines || options.content?.split('\n') || []; + + return { + uri: Uri.file(uri), + fileName: uri, + languageId: options.languageId || 'apexlog', + version: 1, + isDirty: false, + isUntitled: false, + isClosed: false, + eol: 1, + lineCount: lines.length, + getText: jest.fn(() => lines.join('\n')), + lineAt: jest.fn((lineOrPosition: number | Position) => { + const lineNumber = typeof lineOrPosition === 'number' ? lineOrPosition : lineOrPosition.line; + return createMockTextLine(lineNumber, lines[lineNumber] || ''); + }), + positionAt: jest.fn((offset: number) => new Position(0, offset)), + offsetAt: jest.fn((position: Position) => position.line * 100 + position.character), + getWordRangeAtPosition: jest.fn(), + validatePosition: jest.fn((pos: Position) => pos), + validateRange: jest.fn((range: Range) => range), + save: jest.fn().mockResolvedValue(true), + }; +}; + +// Mock workspace +export const workspace = { + workspaceFolders: [] as { uri: ReturnType; name: string; index: number }[], + getConfiguration: jest.fn(() => ({ + get: jest.fn(), + has: jest.fn(() => false), + inspect: jest.fn(), + update: jest.fn().mockResolvedValue(undefined), + })), + onDidChangeConfiguration: jest.fn(() => ({ dispose: jest.fn() })), + onDidCloseTextDocument: jest.fn(() => { + const disposable = { dispose: jest.fn() }; + subscriptions.push(disposable); + return disposable; + }), + onDidOpenTextDocument: jest.fn(() => ({ dispose: jest.fn() })), + onDidChangeTextDocument: jest.fn(() => ({ dispose: jest.fn() })), + onDidSaveTextDocument: jest.fn(() => ({ dispose: jest.fn() })), + openTextDocument: jest.fn(), + fs: { + readFile: jest.fn(), + writeFile: jest.fn(), + stat: jest.fn(), + readDirectory: jest.fn(), + createDirectory: jest.fn(), + delete: jest.fn(), + rename: jest.fn(), + copy: jest.fn(), + }, +}; + +// Mock window +export const window = { + showInformationMessage: jest.fn().mockResolvedValue(undefined), + showWarningMessage: jest.fn().mockResolvedValue(undefined), + showErrorMessage: jest.fn().mockResolvedValue(undefined), + showQuickPick: jest.fn().mockResolvedValue(undefined), + showInputBox: jest.fn().mockResolvedValue(undefined), + createQuickPick: jest.fn(() => ({ + items: [], + selectedItems: [], + activeItems: [], + placeholder: '', + title: '', + step: undefined, + totalSteps: undefined, + enabled: true, + busy: false, + ignoreFocusOut: false, + canSelectMany: false, + matchOnDescription: false, + matchOnDetail: false, + value: '', + onDidChangeValue: jest.fn(() => ({ dispose: jest.fn() })), + onDidAccept: jest.fn(() => ({ dispose: jest.fn() })), + onDidHide: jest.fn(() => ({ dispose: jest.fn() })), + onDidChangeActive: jest.fn(() => ({ dispose: jest.fn() })), + onDidChangeSelection: jest.fn(() => ({ dispose: jest.fn() })), + show: jest.fn(), + hide: jest.fn(), + dispose: jest.fn(), + })), + createOutputChannel: jest.fn(() => ({ + name: 'Test Channel', + append: jest.fn(), + appendLine: jest.fn(), + clear: jest.fn(), + show: jest.fn(), + hide: jest.fn(), + dispose: jest.fn(), + replace: jest.fn(), + })), + createWebviewPanel: jest.fn(), + activeTextEditor: undefined as unknown, + visibleTextEditors: [], + onDidChangeActiveTextEditor: jest.fn(() => ({ dispose: jest.fn() })), + onDidChangeVisibleTextEditors: jest.fn(() => ({ dispose: jest.fn() })), + showTextDocument: jest.fn(), + createTextEditorDecorationType: jest.fn(() => ({ + key: 'mock-decoration-type', + dispose: jest.fn(), + })), + setStatusBarMessage: jest.fn(() => ({ dispose: jest.fn() })), + withProgress: jest.fn((options, task) => task({ report: jest.fn() })), +}; + +// Mock commands +export const commands = { + registerCommand: jest.fn((command: string, callback: (...args: unknown[]) => unknown) => { + const disposable = { dispose: jest.fn() }; + subscriptions.push(disposable); + return disposable; + }), + executeCommand: jest.fn().mockResolvedValue(undefined), + getCommands: jest.fn().mockResolvedValue([]), +}; + +// Mock languages +export const languages = { + registerFoldingRangeProvider: jest.fn((selector, provider) => { + const disposable = { dispose: jest.fn() }; + subscriptions.push(disposable); + return disposable; + }), + registerHoverProvider: jest.fn(() => { + const disposable = { dispose: jest.fn() }; + subscriptions.push(disposable); + return disposable; + }), + registerCodeLensProvider: jest.fn(() => { + const disposable = { dispose: jest.fn() }; + subscriptions.push(disposable); + return disposable; + }), + registerCompletionItemProvider: jest.fn(() => ({ dispose: jest.fn() })), + registerDefinitionProvider: jest.fn(() => ({ dispose: jest.fn() })), + createDiagnosticCollection: jest.fn(() => ({ + name: 'test', + set: jest.fn(), + delete: jest.fn(), + clear: jest.fn(), + forEach: jest.fn(), + get: jest.fn(), + has: jest.fn(), + dispose: jest.fn(), + })), +}; + +// Mock Hover +export class Hover { + contents: unknown; + range?: Range; + + constructor(contents: unknown, range?: Range) { + this.contents = contents; + this.range = range; + } +} + +// Mock MarkdownString +export class MarkdownString { + value: string; + isTrusted: boolean = false; + supportThemeIcons: boolean = false; + supportHtml: boolean = false; + + constructor(value?: string, supportThemeIcons?: boolean) { + this.value = value || ''; + this.supportThemeIcons = supportThemeIcons || false; + } + + appendText(value: string): MarkdownString { + this.value += value; + return this; + } + + appendMarkdown(value: string): MarkdownString { + this.value += value; + return this; + } + + appendCodeblock(value: string, language?: string): MarkdownString { + this.value += `\n\`\`\`${language || ''}\n${value}\n\`\`\`\n`; + return this; + } +} + +// Mock ThemeColor +export class ThemeColor { + id: string; + + constructor(id: string) { + this.id = id; + } +} + +// Mock ConfigurationTarget enum +export const ConfigurationTarget = { + Global: 1, + Workspace: 2, + WorkspaceFolder: 3, +} as const; +export type ConfigurationTarget = (typeof ConfigurationTarget)[keyof typeof ConfigurationTarget]; + +// Mock ExtensionContext +export interface MockExtensionContext { + subscriptions: { dispose: jest.Mock }[]; + workspaceState: { + get: jest.Mock; + update: jest.Mock; + keys: jest.Mock; + }; + globalState: { + get: jest.Mock; + update: jest.Mock; + keys: jest.Mock; + setKeysForSync: jest.Mock; + }; + extensionPath: string; + extensionUri: ReturnType; + storagePath: string | undefined; + storageUri: ReturnType | undefined; + globalStoragePath: string; + globalStorageUri: ReturnType; + logPath: string; + logUri: ReturnType; + asAbsolutePath: jest.Mock; + extension: { + id: string; + extensionUri: ReturnType; + extensionPath: string; + isActive: boolean; + packageJSON: Record; + extensionKind: number; + exports: unknown; + activate: jest.Mock; + }; +} + +export const createMockExtensionContext = (): MockExtensionContext => ({ + subscriptions: [], + workspaceState: { + get: jest.fn(), + update: jest.fn().mockResolvedValue(undefined), + keys: jest.fn(() => []), + }, + globalState: { + get: jest.fn(), + update: jest.fn().mockResolvedValue(undefined), + keys: jest.fn(() => []), + setKeysForSync: jest.fn(), + }, + extensionPath: '/test/extension', + extensionUri: Uri.file('/test/extension'), + storagePath: '/test/storage', + storageUri: Uri.file('/test/storage'), + globalStoragePath: '/test/global-storage', + globalStorageUri: Uri.file('/test/global-storage'), + logPath: '/test/logs', + logUri: Uri.file('/test/logs'), + asAbsolutePath: jest.fn((relativePath: string) => `/test/extension/${relativePath}`), + extension: { + id: 'test.lana', + extensionUri: Uri.file('/test/extension'), + extensionPath: '/test/extension', + isActive: true, + packageJSON: { name: 'lana', version: '1.0.0' }, + extensionKind: 1, + exports: undefined, + activate: jest.fn().mockResolvedValue(undefined), + }, +}); + +// Reset function for cleaning up between tests +export const resetMocks = (): void => { + // Clear all subscriptions + subscriptions.length = 0; + + // Reset all mock functions + jest.clearAllMocks(); + + // Reset workspace folders + workspace.workspaceFolders = []; + + // Reset active editor + window.activeTextEditor = undefined; + window.visibleTextEditors = []; +}; + +// Export as default for module replacement +export default { + Position, + Range, + Uri, + FoldingRange, + FoldingRangeKind, + Hover, + MarkdownString, + ThemeColor, + ConfigurationTarget, + workspace, + window, + commands, + languages, + createMockTextDocument, + createMockExtensionContext, + resetMocks, +}; diff --git a/lana/src/__tests__/setup.ts b/lana/src/__tests__/setup.ts new file mode 100644 index 00000000..e2c1bb5f --- /dev/null +++ b/lana/src/__tests__/setup.ts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * Jest setup file for lana tests. + * Auto-injects vscode mock and resets state between tests. + */ + +import { resetMocks } from './mocks/vscode.js'; + +// Reset mock state before each test +beforeEach(() => { + resetMocks(); +}); + +// Clear all mocks after each test +afterEach(() => { + jest.clearAllMocks(); +}); diff --git a/lana/src/cache/__tests__/LogEventCache.test.ts b/lana/src/cache/__tests__/LogEventCache.test.ts new file mode 100644 index 00000000..9f60555b --- /dev/null +++ b/lana/src/cache/__tests__/LogEventCache.test.ts @@ -0,0 +1,423 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +import { workspace } from 'vscode'; + +import { + createMockApexLog, + createMockContext, + createMockLogEvent, +} from '../../__tests__/helpers/test-builders.js'; +import { LogEventCache } from '../LogEventCache.js'; + +// Mock fs/promises +jest.mock('fs/promises', () => ({ + readFile: jest.fn(), +})); + +// Mock apex-log-parser +jest.mock('apex-log-parser', () => ({ + parse: jest.fn(), +})); + +import { parse } from 'apex-log-parser'; +import { readFile } from 'fs/promises'; + +const mockReadFile = readFile as jest.Mock; +const mockParse = parse as jest.Mock; + +describe('LogEventCache', () => { + beforeEach(() => { + // Clear the cache between tests by accessing private static + // @ts-expect-error - accessing private static for testing + LogEventCache.cache.clear(); + }); + + describe('getApexLog', () => { + describe('cache behavior', () => { + it('should return cached ApexLog on subsequent calls', async () => { + const mockApexLog = createMockApexLog({ size: 1000 }); + mockReadFile.mockResolvedValueOnce('log content'); + mockParse.mockReturnValueOnce(mockApexLog); + + // First call - should read and parse + const result1 = await LogEventCache.getApexLog('/test/file.log'); + expect(result1).toBe(mockApexLog); + expect(mockReadFile).toHaveBeenCalledTimes(1); + + // Second call - should return cached + const result2 = await LogEventCache.getApexLog('/test/file.log'); + expect(result2).toBe(mockApexLog); + expect(mockReadFile).toHaveBeenCalledTimes(1); // Still 1 + }); + + it('should move accessed item to end (most recently used)', async () => { + const log1 = createMockApexLog({ size: 100 }); + const log2 = createMockApexLog({ size: 200 }); + + mockReadFile.mockResolvedValueOnce('content1').mockResolvedValueOnce('content2'); + mockParse.mockReturnValueOnce(log1).mockReturnValueOnce(log2); + + await LogEventCache.getApexLog('/test/file1.log'); + await LogEventCache.getApexLog('/test/file2.log'); + + // Access file1 again - should move to end + await LogEventCache.getApexLog('/test/file1.log'); + + // @ts-expect-error - accessing private static for testing + const keys = Array.from(LogEventCache.cache.keys()); + expect(keys).toEqual(['/test/file2.log', '/test/file1.log']); + }); + + it('should evict oldest entry when cache reaches MAX_CACHE_SIZE', async () => { + // Create 11 logs to trigger eviction (MAX_CACHE_SIZE is 10) + for (let i = 0; i < 11; i++) { + const mockLog = createMockApexLog({ size: i * 100 }); + mockReadFile.mockResolvedValueOnce(`content${i}`); + mockParse.mockReturnValueOnce(mockLog); + + await LogEventCache.getApexLog(`/test/file${i}.log`); + } + + // @ts-expect-error - accessing private static for testing + const cacheSize = LogEventCache.cache.size; + expect(cacheSize).toBe(10); + + // First file should be evicted + // @ts-expect-error - accessing private static for testing + const hasFirst = LogEventCache.cache.has('/test/file0.log'); + expect(hasFirst).toBe(false); + + // Last file should exist + // @ts-expect-error - accessing private static for testing + const hasLast = LogEventCache.cache.has('/test/file10.log'); + expect(hasLast).toBe(true); + }); + + it('should return null when file read fails', async () => { + mockReadFile.mockRejectedValueOnce(new Error('File not found')); + + const result = await LogEventCache.getApexLog('/test/nonexistent.log'); + + expect(result).toBeNull(); + }); + + it('should return null when parse fails', async () => { + mockReadFile.mockResolvedValueOnce('invalid content'); + mockParse.mockImplementationOnce(() => { + throw new Error('Parse error'); + }); + + const result = await LogEventCache.getApexLog('/test/invalid.log'); + + expect(result).toBeNull(); + }); + }); + }); + + describe('findEventByTimestamp', () => { + describe('binary search', () => { + it('should find event with exact timestamp match', () => { + const event = createMockLogEvent({ timestamp: 1000, exitStamp: 2000 }); + const apexLog = createMockApexLog({ children: [event] }); + + const result = LogEventCache.findEventByTimestamp(apexLog, 1000); + + expect(result).toEqual({ event, depth: 0 }); + }); + + it('should find event when timestamp is within range', () => { + const event = createMockLogEvent({ timestamp: 1000, exitStamp: 3000 }); + const apexLog = createMockApexLog({ children: [event] }); + + const result = LogEventCache.findEventByTimestamp(apexLog, 2000); + + expect(result).toEqual({ event, depth: 0 }); + }); + + it('should find event at end of range', () => { + const event = createMockLogEvent({ timestamp: 1000, exitStamp: 3000 }); + const apexLog = createMockApexLog({ children: [event] }); + + const result = LogEventCache.findEventByTimestamp(apexLog, 3000); + + expect(result).toEqual({ event, depth: 0 }); + }); + + it('should return null when timestamp is before all events', () => { + const event = createMockLogEvent({ timestamp: 1000, exitStamp: 2000 }); + const apexLog = createMockApexLog({ children: [event] }); + + const result = LogEventCache.findEventByTimestamp(apexLog, 500); + + expect(result).toBeNull(); + }); + + it('should return null when timestamp is after all events', () => { + const event = createMockLogEvent({ timestamp: 1000, exitStamp: 2000 }); + const apexLog = createMockApexLog({ children: [event] }); + + const result = LogEventCache.findEventByTimestamp(apexLog, 3000); + + expect(result).toBeNull(); + }); + + it('should find correct event among multiple events', () => { + const event1 = createMockLogEvent({ timestamp: 1000, exitStamp: 2000 }); + const event2 = createMockLogEvent({ timestamp: 3000, exitStamp: 4000 }); + const event3 = createMockLogEvent({ timestamp: 5000, exitStamp: 6000 }); + const apexLog = createMockApexLog({ children: [event1, event2, event3] }); + + const result = LogEventCache.findEventByTimestamp(apexLog, 3500); + + expect(result).toEqual({ event: event2, depth: 0 }); + }); + + it('should find event in gap between sibling events', () => { + const event1 = createMockLogEvent({ timestamp: 1000, exitStamp: 2000 }); + const event2 = createMockLogEvent({ timestamp: 4000, exitStamp: 5000 }); + const apexLog = createMockApexLog({ children: [event1, event2] }); + + // Timestamp 3000 is between event1 end and event2 start + const result = LogEventCache.findEventByTimestamp(apexLog, 3000); + + expect(result).toBeNull(); + }); + }); + + describe('nested events', () => { + it('should search children and find nested event', () => { + const childEvent = createMockLogEvent({ timestamp: 1200, exitStamp: 1800 }); + const parentEvent = createMockLogEvent({ + timestamp: 1000, + exitStamp: 2000, + children: [childEvent], + }); + const apexLog = createMockApexLog({ children: [parentEvent] }); + + const result = LogEventCache.findEventByTimestamp(apexLog, 1500); + + expect(result).toEqual({ event: childEvent, depth: 1 }); + }); + + it('should find deeply nested event at correct depth', () => { + const grandchild = createMockLogEvent({ timestamp: 1300, exitStamp: 1700 }); + const child = createMockLogEvent({ + timestamp: 1200, + exitStamp: 1800, + children: [grandchild], + }); + const parent = createMockLogEvent({ + timestamp: 1000, + exitStamp: 2000, + children: [child], + }); + const apexLog = createMockApexLog({ children: [parent] }); + + const result = LogEventCache.findEventByTimestamp(apexLog, 1500); + + expect(result).toEqual({ event: grandchild, depth: 2 }); + }); + + it('should return parent when timestamp is outside child ranges', () => { + const child = createMockLogEvent({ timestamp: 1300, exitStamp: 1400 }); + const parent = createMockLogEvent({ + timestamp: 1000, + exitStamp: 2000, + children: [child], + }); + const apexLog = createMockApexLog({ children: [parent] }); + + // 1500 is after child ends but before parent ends + const result = LogEventCache.findEventByTimestamp(apexLog, 1500); + + expect(result).toEqual({ event: parent, depth: 0 }); + }); + + it('should handle events with multiple children at same level', () => { + const child1 = createMockLogEvent({ timestamp: 1100, exitStamp: 1300 }); + const child2 = createMockLogEvent({ timestamp: 1400, exitStamp: 1600 }); + const child3 = createMockLogEvent({ timestamp: 1700, exitStamp: 1900 }); + const parent = createMockLogEvent({ + timestamp: 1000, + exitStamp: 2000, + children: [child1, child2, child3], + }); + const apexLog = createMockApexLog({ children: [parent] }); + + const result = LogEventCache.findEventByTimestamp(apexLog, 1500); + + expect(result).toEqual({ event: child2, depth: 1 }); + }); + }); + + describe('edge cases', () => { + it('should return null for empty events array', () => { + const apexLog = createMockApexLog({ children: [] }); + + const result = LogEventCache.findEventByTimestamp(apexLog, 1000); + + expect(result).toBeNull(); + }); + + it('should handle single event in array', () => { + const event = createMockLogEvent({ timestamp: 1000, exitStamp: 2000 }); + const apexLog = createMockApexLog({ children: [event] }); + + const result = LogEventCache.findEventByTimestamp(apexLog, 1500); + + expect(result).toEqual({ event, depth: 0 }); + }); + + it('should handle event with null exitStamp (use timestamp as end)', () => { + const event = createMockLogEvent({ timestamp: 1000, exitStamp: null }); + const apexLog = createMockApexLog({ children: [event] }); + + // Should only match exact timestamp when exitStamp is null + const exactResult = LogEventCache.findEventByTimestamp(apexLog, 1000); + expect(exactResult).toEqual({ event, depth: 0 }); + + const afterResult = LogEventCache.findEventByTimestamp(apexLog, 1001); + expect(afterResult).toBeNull(); + }); + + it('should handle event where exitStamp equals timestamp', () => { + const event = createMockLogEvent({ timestamp: 1000, exitStamp: 1000 }); + const apexLog = createMockApexLog({ children: [event] }); + + const result = LogEventCache.findEventByTimestamp(apexLog, 1000); + + expect(result).toEqual({ event, depth: 0 }); + }); + + it('should handle large number of events', () => { + const events = []; + for (let i = 0; i < 100; i++) { + events.push( + createMockLogEvent({ + timestamp: i * 100, + exitStamp: i * 100 + 50, + }), + ); + } + const apexLog = createMockApexLog({ children: events }); + + // Search for event in the middle + const result = LogEventCache.findEventByTimestamp(apexLog, 5025); + + expect(result?.event.timestamp).toBe(5000); + expect(result?.depth).toBe(0); + }); + }); + }); + + describe('clearCache', () => { + it('should remove specific entry from cache', async () => { + const mockApexLog = createMockApexLog(); + mockReadFile.mockResolvedValueOnce('content'); + mockParse.mockReturnValueOnce(mockApexLog); + + await LogEventCache.getApexLog('/test/file.log'); + + // @ts-expect-error - accessing private static for testing + expect(LogEventCache.cache.has('/test/file.log')).toBe(true); + + LogEventCache.clearCache('/test/file.log'); + + // @ts-expect-error - accessing private static for testing + expect(LogEventCache.cache.has('/test/file.log')).toBe(false); + }); + + it('should not affect other cached entries', async () => { + const log1 = createMockApexLog({ size: 100 }); + const log2 = createMockApexLog({ size: 200 }); + + mockReadFile.mockResolvedValueOnce('content1').mockResolvedValueOnce('content2'); + mockParse.mockReturnValueOnce(log1).mockReturnValueOnce(log2); + + await LogEventCache.getApexLog('/test/file1.log'); + await LogEventCache.getApexLog('/test/file2.log'); + + LogEventCache.clearCache('/test/file1.log'); + + // @ts-expect-error - accessing private static for testing + expect(LogEventCache.cache.has('/test/file1.log')).toBe(false); + // @ts-expect-error - accessing private static for testing + expect(LogEventCache.cache.has('/test/file2.log')).toBe(true); + }); + + it('should handle clearing non-existent entry gracefully', () => { + expect(() => { + LogEventCache.clearCache('/test/nonexistent.log'); + }).not.toThrow(); + }); + }); + + describe('apply', () => { + it('should register onDidCloseTextDocument listener', () => { + const mockContext = createMockContext(); + + LogEventCache.apply(mockContext as unknown as import('../../Context.js').Context); + + expect(workspace.onDidCloseTextDocument).toHaveBeenCalledTimes(1); + expect(mockContext.context.subscriptions.length).toBe(1); + }); + + it('should clear cache when apexlog document is closed', async () => { + // Setup cache + const mockApexLog = createMockApexLog(); + mockReadFile.mockResolvedValueOnce('content'); + mockParse.mockReturnValueOnce(mockApexLog); + await LogEventCache.getApexLog('/test/file.log'); + + // Capture the callback + let closeCallback: ((doc: { languageId: string; uri: { fsPath: string } }) => void) | null = + null; + (workspace.onDidCloseTextDocument as jest.Mock).mockImplementationOnce((cb) => { + closeCallback = cb; + return { dispose: jest.fn() }; + }); + + const mockContext = createMockContext(); + LogEventCache.apply(mockContext as unknown as import('../../Context.js').Context); + + // Simulate closing an apexlog document + closeCallback!({ + languageId: 'apexlog', + uri: { fsPath: '/test/file.log' }, + }); + + // @ts-expect-error - accessing private static for testing + expect(LogEventCache.cache.has('/test/file.log')).toBe(false); + }); + + it('should not clear cache when non-apexlog document is closed', async () => { + // Setup cache + const mockApexLog = createMockApexLog(); + mockReadFile.mockResolvedValueOnce('content'); + mockParse.mockReturnValueOnce(mockApexLog); + await LogEventCache.getApexLog('/test/file.log'); + + // Capture the callback + let closeCallback: ((doc: { languageId: string; uri: { fsPath: string } }) => void) | null = + null; + (workspace.onDidCloseTextDocument as jest.Mock).mockImplementationOnce((cb) => { + closeCallback = cb; + return { dispose: jest.fn() }; + }); + + const mockContext = createMockContext(); + LogEventCache.apply(mockContext as unknown as import('../../Context.js').Context); + + // Simulate closing a non-apexlog document + closeCallback!({ + languageId: 'javascript', + uri: { fsPath: '/test/file.log' }, + }); + + // @ts-expect-error - accessing private static for testing + expect(LogEventCache.cache.has('/test/file.log')).toBe(true); + }); + }); +}); diff --git a/lana/src/commands/RetrieveLogFile.ts b/lana/src/commands/RetrieveLogFile.ts index e4ebe5c5..3c44f0a0 100644 --- a/lana/src/commands/RetrieveLogFile.ts +++ b/lana/src/commands/RetrieveLogFile.ts @@ -41,11 +41,10 @@ export class RetrieveLogFile { private static async safeCommand(context: Context): Promise { try { - return RetrieveLogFile.command(context); + return await RetrieveLogFile.command(context); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); context.display.showErrorMessage(`Error loading logfile: ${msg}`); - return Promise.resolve(); } } diff --git a/lana/src/commands/__tests__/RetrieveLogFile.test.ts b/lana/src/commands/__tests__/RetrieveLogFile.test.ts new file mode 100644 index 00000000..759d9cf0 --- /dev/null +++ b/lana/src/commands/__tests__/RetrieveLogFile.test.ts @@ -0,0 +1,758 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * Tests for RetrieveLogFile command, focusing on the private formatDuration method. + * Since formatDuration is private, we test it indirectly through its usage in getLogFile. + */ + +import { window } from 'vscode'; + +import { createMockContext } from '../../__tests__/helpers/test-builders.js'; +import { RetrieveLogFile } from '../RetrieveLogFile.js'; + +// Mock dependencies +jest.mock('../../display/QuickPickWorkspace.js', () => ({ + QuickPickWorkspace: { + pickOrReturn: jest.fn(), + }, +})); + +jest.mock('../../salesforce/logs/GetLogFiles.js', () => ({ + GetLogFiles: { + apply: jest.fn(), + }, +})); + +jest.mock('../../salesforce/logs/GetLogFile.js', () => ({ + GetLogFile: { + apply: jest.fn(), + }, +})); + +jest.mock('../LogView.js', () => ({ + LogView: { + createView: jest.fn(), + }, +})); + +jest.mock('../../display/QuickPick.js', () => ({ + QuickPick: { + pick: jest.fn(), + }, + Item: class { + name: string; + desc: string; + details: string; + sticky: boolean; + selected: boolean; + constructor(name: string, desc: string, details: string, sticky: boolean, selected: boolean) { + this.name = name; + this.desc = desc; + this.details = details; + this.sticky = sticky; + this.selected = selected; + } + }, + Options: class { + placeholder: string; + constructor(placeholder: string) { + this.placeholder = placeholder; + } + }, +})); + +jest.mock('fs', () => ({ + existsSync: jest.fn(), +})); + +import { existsSync } from 'fs'; +import { commands } from 'vscode'; + +import { QuickPick } from '../../display/QuickPick.js'; +import { QuickPickWorkspace } from '../../display/QuickPickWorkspace.js'; +import { GetLogFile } from '../../salesforce/logs/GetLogFile.js'; +import { GetLogFiles } from '../../salesforce/logs/GetLogFiles.js'; +import { LogView } from '../LogView.js'; + +const mockPickOrReturn = QuickPickWorkspace.pickOrReturn as jest.Mock; +const mockGetLogFiles = GetLogFiles.apply as jest.Mock; +const mockGetLogFile = GetLogFile.apply as jest.Mock; +const mockQuickPickPick = QuickPick.pick as jest.Mock; +const mockExistsSync = existsSync as jest.Mock; +const mockCreateView = LogView.createView as jest.Mock; +const mockRegisterCommand = commands.registerCommand as jest.Mock; + +describe('RetrieveLogFile', () => { + beforeEach(() => { + mockPickOrReturn.mockResolvedValue('/test/workspace'); + mockGetLogFiles.mockResolvedValue([]); + mockQuickPickPick.mockResolvedValue([]); + mockExistsSync.mockReturnValue(false); + (window.createQuickPick as jest.Mock).mockReturnValue({ + items: [], + busy: false, + enabled: true, + placeholder: '', + show: jest.fn(), + hide: jest.fn(), + dispose: jest.fn(), + }); + }); + + describe('apply', () => { + it('should register command with context', () => { + const mockContext = createMockContext(); + + RetrieveLogFile.apply(mockContext as unknown as import('../../Context.js').Context); + + expect(mockContext.context.subscriptions.length).toBe(1); + }); + + it('should output registration message', () => { + const mockContext = createMockContext(); + + RetrieveLogFile.apply(mockContext as unknown as import('../../Context.js').Context); + + expect(mockContext.display.output).toHaveBeenCalledWith( + "Registered command 'Lana: Retrieve Log'", + ); + }); + }); + + describe('error handling', () => { + it('should register command even when errors occur during execution', () => { + const mockContext = createMockContext(); + RetrieveLogFile.apply(mockContext as unknown as import('../../Context.js').Context); + + // The error handling is tested by verifying the command is registered + expect(mockContext.context.subscriptions.length).toBe(1); + }); + }); + + describe('command execution flow', () => { + /** + * Helper to get the registered command callback. + * The Command class stores callbacks via commands.registerCommand. + */ + const getCommandCallback = (): (() => Promise) => { + // Get the most recent call's callback (second argument) + const lastCall = mockRegisterCommand.mock.calls[mockRegisterCommand.mock.calls.length - 1]; + return lastCall[1]; + }; + + it('should call QuickPickWorkspace.pickOrReturn first', async () => { + mockPickOrReturn.mockResolvedValue('/test/workspace'); + mockGetLogFiles.mockResolvedValue([]); + mockQuickPickPick.mockResolvedValue([]); + + const mockContext = createMockContext(); + RetrieveLogFile.apply(mockContext as unknown as import('../../Context.js').Context); + + const commandCallback = getCommandCallback(); + await commandCallback(); + + expect(mockPickOrReturn).toHaveBeenCalled(); + }); + + it('should call GetLogFiles.apply with workspace path', async () => { + mockPickOrReturn.mockResolvedValue('/my/workspace'); + mockGetLogFiles.mockResolvedValue([]); + mockQuickPickPick.mockResolvedValue([]); + + const mockContext = createMockContext(); + RetrieveLogFile.apply(mockContext as unknown as import('../../Context.js').Context); + + const commandCallback = getCommandCallback(); + await commandCallback(); + + expect(mockGetLogFiles).toHaveBeenCalledWith('/my/workspace'); + }); + + it('should return undefined when no log is selected', async () => { + mockPickOrReturn.mockResolvedValue('/test/workspace'); + mockGetLogFiles.mockResolvedValue([ + { + Id: 'log1', + LogUser: { Name: 'User' }, + Operation: 'Op', + LogLength: 1024, + DurationMilliseconds: 100, + StartTime: '2024-01-01T00:00:00.000Z', + Status: 'Success', + }, + ]); + // Return empty array (user cancelled) + mockQuickPickPick.mockResolvedValue([]); + + const mockContext = createMockContext(); + RetrieveLogFile.apply(mockContext as unknown as import('../../Context.js').Context); + + const commandCallback = getCommandCallback(); + const result = await commandCallback(); + + expect(result).toBeUndefined(); + expect(mockCreateView).not.toHaveBeenCalled(); + }); + + it('should create view when log is selected', async () => { + mockPickOrReturn.mockResolvedValue('/test/workspace'); + mockGetLogFiles.mockResolvedValue([ + { + Id: 'selected-log-id', + LogUser: { Name: 'User' }, + Operation: 'Op', + LogLength: 1024, + DurationMilliseconds: 100, + StartTime: '2024-01-01T00:00:00.000Z', + Status: 'Success', + }, + ]); + // Return selected item with logId + mockQuickPickPick.mockResolvedValue([{ logId: 'selected-log-id' }]); + mockExistsSync.mockReturnValue(false); + mockGetLogFile.mockResolvedValue(undefined); + mockCreateView.mockResolvedValue({ panel: 'mock' }); + + const mockContext = createMockContext(); + RetrieveLogFile.apply(mockContext as unknown as import('../../Context.js').Context); + + const commandCallback = getCommandCallback(); + await commandCallback(); + + expect(mockCreateView).toHaveBeenCalled(); + const createViewCall = mockCreateView.mock.calls[0]; + expect(createViewCall[2]).toContain('selected-log-id.log'); + }); + + it('should skip download when log file already exists', async () => { + mockPickOrReturn.mockResolvedValue('/test/workspace'); + mockGetLogFiles.mockResolvedValue([ + { + Id: 'existing-log', + LogUser: { Name: 'User' }, + Operation: 'Op', + LogLength: 1024, + DurationMilliseconds: 100, + StartTime: '2024-01-01T00:00:00.000Z', + Status: 'Success', + }, + ]); + mockQuickPickPick.mockResolvedValue([{ logId: 'existing-log' }]); + // File already exists + mockExistsSync.mockReturnValue(true); + mockCreateView.mockResolvedValue({ panel: 'mock' }); + + const mockContext = createMockContext(); + RetrieveLogFile.apply(mockContext as unknown as import('../../Context.js').Context); + + const commandCallback = getCommandCallback(); + await commandCallback(); + + // GetLogFile should NOT be called since file exists + expect(mockGetLogFile).not.toHaveBeenCalled(); + expect(mockCreateView).toHaveBeenCalled(); + }); + + it('should download log file when it does not exist', async () => { + mockPickOrReturn.mockResolvedValue('/test/workspace'); + mockGetLogFiles.mockResolvedValue([ + { + Id: 'new-log', + LogUser: { Name: 'User' }, + Operation: 'Op', + LogLength: 1024, + DurationMilliseconds: 100, + StartTime: '2024-01-01T00:00:00.000Z', + Status: 'Success', + }, + ]); + mockQuickPickPick.mockResolvedValue([{ logId: 'new-log' }]); + // File does not exist + mockExistsSync.mockReturnValue(false); + mockGetLogFile.mockResolvedValue(undefined); + mockCreateView.mockResolvedValue({ panel: 'mock' }); + + const mockContext = createMockContext(); + RetrieveLogFile.apply(mockContext as unknown as import('../../Context.js').Context); + + const commandCallback = getCommandCallback(); + await commandCallback(); + + // GetLogFile SHOULD be called since file doesn't exist + expect(mockGetLogFile).toHaveBeenCalledWith( + '/test/workspace', + expect.stringContaining('.sfdx/tools/debug/logs'), + 'new-log', + ); + }); + }); + + describe('formatDuration via getLogFile', () => { + /** + * Tests for the private formatDuration method, tested indirectly through getLogFile. + * formatDuration converts milliseconds to human-readable strings. + */ + + const createLogWithDuration = (durationMs: number) => ({ + Id: 'test-log', + LogUser: { Name: 'User' }, + Operation: 'Op', + LogLength: 1024, + DurationMilliseconds: durationMs, + StartTime: '2024-01-01T00:00:00.000Z', + Status: 'Success', + }); + + const getCapturedDescription = async (durationMs: number): Promise => { + mockPickOrReturn.mockResolvedValue('/test/workspace'); + mockGetLogFiles.mockResolvedValue([createLogWithDuration(durationMs)]); + + let capturedDesc = ''; + mockQuickPickPick.mockImplementation((items: Array<{ desc: string }>) => { + capturedDesc = items[0]?.desc || ''; + return Promise.resolve([]); + }); + + const mockContext = createMockContext(); + RetrieveLogFile.apply(mockContext as unknown as import('../../Context.js').Context); + + const lastCall = mockRegisterCommand.mock.calls[mockRegisterCommand.mock.calls.length - 1]; + await lastCall[1](); + + return capturedDesc; + }; + + it('should format 0 ms as "0 ms"', async () => { + const desc = await getCapturedDescription(0); + expect(desc).toContain('0 ms'); + }); + + it('should format values < 10 ms with 2 decimal precision', async () => { + const desc = await getCapturedDescription(5.123); + // _round(5.123, 100) = 5.12 + expect(desc).toContain('5.12 ms'); + }); + + it('should format values 10-99 ms with 1 decimal precision', async () => { + const desc = await getCapturedDescription(45.67); + // _round(45.67, 10) = 45.7 + expect(desc).toContain('45.7 ms'); + }); + + it('should format values >= 100 ms with no decimal precision', async () => { + const desc = await getCapturedDescription(789.4); + // _round(789.4, 1) = 789 + expect(desc).toContain('789 ms'); + }); + + it('should format 1-9.99 seconds with 2 decimal precision', async () => { + const desc = await getCapturedDescription(1234); + // 1.234s, _round(1.234, 100) = 1.23 + expect(desc).toContain('1.23 s'); + }); + + it('should format 10-59.99 seconds with 1 decimal precision', async () => { + const desc = await getCapturedDescription(45678); + // 45.678s, _round(45.678, 10) = 45.7 + expect(desc).toContain('45.7 s'); + }); + + it('should format exact minutes without seconds', async () => { + const desc = await getCapturedDescription(120000); + // 120s = 2m exactly + expect(desc).toContain('2m'); + expect(desc).not.toContain('2m '); + }); + + it('should format minutes with whole seconds', async () => { + const desc = await getCapturedDescription(150000); + // 150s = 2m 30s + expect(desc).toContain('2m 30s'); + }); + + it('should format minutes with fractional seconds', async () => { + const desc = await getCapturedDescription(125500); + // 125.5s = 2m 5.5s + expect(desc).toContain('2m 5.5s'); + }); + + it('should format large durations in minutes', async () => { + const desc = await getCapturedDescription(300000); + // 300s = 5m exactly + expect(desc).toContain('5m'); + }); + + it('should format undefined/falsy duration as "0 ms"', async () => { + // Test with undefined cast to number (becomes NaN which is falsy) + mockPickOrReturn.mockResolvedValue('/test/workspace'); + mockGetLogFiles.mockResolvedValue([ + { + Id: 'test-log', + LogUser: { Name: 'User' }, + Operation: 'Op', + LogLength: 1024, + DurationMilliseconds: undefined as unknown as number, + StartTime: '2024-01-01T00:00:00.000Z', + Status: 'Success', + }, + ]); + + let capturedDesc = ''; + mockQuickPickPick.mockImplementation((items: Array<{ desc: string }>) => { + capturedDesc = items[0]?.desc || ''; + return Promise.resolve([]); + }); + + const mockContext = createMockContext(); + RetrieveLogFile.apply(mockContext as unknown as import('../../Context.js').Context); + + const lastCall = mockRegisterCommand.mock.calls[mockRegisterCommand.mock.calls.length - 1]; + await lastCall[1](); + + expect(capturedDesc).toContain('0 ms'); + }); + }); + + describe('getLogFile behavior', () => { + it('should sort logs newest first', async () => { + const logs = [ + { + Id: 'oldest', + LogUser: { Name: 'User' }, + Operation: 'Op', + LogLength: 1024, + DurationMilliseconds: 100, + StartTime: '2024-01-01T00:00:00.000Z', + Status: 'Success', + }, + { + Id: 'newest', + LogUser: { Name: 'User' }, + Operation: 'Op', + LogLength: 1024, + DurationMilliseconds: 100, + StartTime: '2024-01-03T00:00:00.000Z', + Status: 'Success', + }, + { + Id: 'middle', + LogUser: { Name: 'User' }, + Operation: 'Op', + LogLength: 1024, + DurationMilliseconds: 100, + StartTime: '2024-01-02T00:00:00.000Z', + Status: 'Success', + }, + ]; + + mockPickOrReturn.mockResolvedValue('/test/workspace'); + mockGetLogFiles.mockResolvedValue(logs); + + // Capture the items passed to QuickPick + let capturedItems: Array<{ logId: string }> = []; + mockQuickPickPick.mockImplementation((items) => { + capturedItems = items; + return Promise.resolve([]); + }); + + const mockContext = createMockContext(); + RetrieveLogFile.apply(mockContext as unknown as import('../../Context.js').Context); + + const lastCall = mockRegisterCommand.mock.calls[mockRegisterCommand.mock.calls.length - 1]; + await lastCall[1](); + + // Verify items are sorted newest first + expect(capturedItems).toHaveLength(3); + expect(capturedItems[0]?.logId).toBe('newest'); + expect(capturedItems[1]?.logId).toBe('middle'); + expect(capturedItems[2]?.logId).toBe('oldest'); + }); + + it('should format log item name as "User - Operation"', async () => { + mockPickOrReturn.mockResolvedValue('/test/workspace'); + mockGetLogFiles.mockResolvedValue([ + { + Id: 'log1', + LogUser: { Name: 'John Doe' }, + Operation: '/apex/MyController', + LogLength: 2048, + DurationMilliseconds: 500, + StartTime: '2024-01-01T00:00:00.000Z', + Status: 'Success', + }, + ]); + + let capturedItems: Array<{ name: string }> = []; + mockQuickPickPick.mockImplementation((items) => { + capturedItems = items; + return Promise.resolve([]); + }); + + const mockContext = createMockContext(); + RetrieveLogFile.apply(mockContext as unknown as import('../../Context.js').Context); + + const lastCall = mockRegisterCommand.mock.calls[mockRegisterCommand.mock.calls.length - 1]; + await lastCall[1](); + + expect(capturedItems).toHaveLength(1); + expect(capturedItems[0]?.name).toBe('John Doe - /apex/MyController'); + }); + + it('should format log item description with size in KB and duration', async () => { + mockPickOrReturn.mockResolvedValue('/test/workspace'); + mockGetLogFiles.mockResolvedValue([ + { + Id: 'log1', + LogUser: { Name: 'User' }, + Operation: 'Op', + LogLength: 5120, // 5 KB + DurationMilliseconds: 1500, // 1.5s + StartTime: '2024-01-01T00:00:00.000Z', + Status: 'Success', + }, + ]); + + let capturedItems: Array<{ desc: string }> = []; + mockQuickPickPick.mockImplementation((items) => { + capturedItems = items; + return Promise.resolve([]); + }); + + const mockContext = createMockContext(); + RetrieveLogFile.apply(mockContext as unknown as import('../../Context.js').Context); + + const lastCall = mockRegisterCommand.mock.calls[mockRegisterCommand.mock.calls.length - 1]; + await lastCall[1](); + + expect(capturedItems).toHaveLength(1); + expect(capturedItems[0]?.desc).toContain('5.00 KB'); + expect(capturedItems[0]?.desc).toContain('1.5 s'); + }); + + it('should format log item detail with date, status, and ID', async () => { + mockPickOrReturn.mockResolvedValue('/test/workspace'); + mockGetLogFiles.mockResolvedValue([ + { + Id: 'ABC123XYZ', + LogUser: { Name: 'User' }, + Operation: 'Op', + LogLength: 1024, + DurationMilliseconds: 100, + StartTime: '2024-06-15T10:30:00.000Z', + Status: 'Success', + }, + ]); + + let capturedItems: Array<{ details: string }> = []; + mockQuickPickPick.mockImplementation((items) => { + capturedItems = items; + return Promise.resolve([]); + }); + + const mockContext = createMockContext(); + RetrieveLogFile.apply(mockContext as unknown as import('../../Context.js').Context); + + const lastCall = mockRegisterCommand.mock.calls[mockRegisterCommand.mock.calls.length - 1]; + await lastCall[1](); + + expect(capturedItems).toHaveLength(1); + expect(capturedItems[0]?.details).toContain('Success'); + expect(capturedItems[0]?.details).toContain('ABC123XYZ'); + }); + + it('should return null when QuickPick returns empty array', async () => { + mockPickOrReturn.mockResolvedValue('/test/workspace'); + mockGetLogFiles.mockResolvedValue([ + { + Id: 'log1', + LogUser: { Name: 'User' }, + Operation: 'Op', + LogLength: 1024, + DurationMilliseconds: 100, + StartTime: '2024-01-01T00:00:00.000Z', + Status: 'Success', + }, + ]); + mockQuickPickPick.mockResolvedValue([]); + + const mockContext = createMockContext(); + RetrieveLogFile.apply(mockContext as unknown as import('../../Context.js').Context); + + const lastCall = mockRegisterCommand.mock.calls[mockRegisterCommand.mock.calls.length - 1]; + const result = await lastCall[1](); + + expect(result).toBeUndefined(); + expect(mockCreateView).not.toHaveBeenCalled(); + }); + }); + + describe('safeCommand error handling', () => { + it('should catch Error and display error message', async () => { + const mockContext = createMockContext(); + RetrieveLogFile.apply(mockContext as unknown as import('../../Context.js').Context); + + // Setup rejection using implementation + mockPickOrReturn.mockImplementationOnce(() => + Promise.reject(new Error('Test error message')), + ); + + const lastCall = mockRegisterCommand.mock.calls[mockRegisterCommand.mock.calls.length - 1]; + await lastCall[1](); + + expect(mockContext.display.showErrorMessage).toHaveBeenCalledWith( + 'Error loading logfile: Test error message', + ); + }); + + it('should convert non-Error to string in error message', async () => { + const mockContext = createMockContext(); + RetrieveLogFile.apply(mockContext as unknown as import('../../Context.js').Context); + + // Setup rejection using implementation - throw a non-Error value + mockPickOrReturn.mockImplementationOnce(() => Promise.reject('String error')); + + const lastCall = mockRegisterCommand.mock.calls[mockRegisterCommand.mock.calls.length - 1]; + await lastCall[1](); + + expect(mockContext.display.showErrorMessage).toHaveBeenCalledWith( + 'Error loading logfile: String error', + ); + }); + + it('should return undefined on error (not reject)', async () => { + const mockContext = createMockContext(); + RetrieveLogFile.apply(mockContext as unknown as import('../../Context.js').Context); + + // Setup rejection using implementation + mockPickOrReturn.mockImplementationOnce(() => Promise.reject(new Error('Some error'))); + + const lastCall = mockRegisterCommand.mock.calls[mockRegisterCommand.mock.calls.length - 1]; + const result = await lastCall[1](); + + // Should resolve (not reject) with undefined + expect(result).toBeUndefined(); + }); + }); + + describe('getLogFilePath', () => { + it('should construct path with .sfdx/tools/debug/logs directory', async () => { + mockPickOrReturn.mockResolvedValue('/my/project'); + mockGetLogFiles.mockResolvedValue([ + { + Id: 'test-log-123', + LogUser: { Name: 'User' }, + Operation: 'Op', + LogLength: 1024, + DurationMilliseconds: 100, + StartTime: '2024-01-01T00:00:00.000Z', + Status: 'Success', + }, + ]); + mockQuickPickPick.mockResolvedValue([{ logId: 'test-log-123' }]); + mockExistsSync.mockReturnValue(true); + mockCreateView.mockResolvedValue({ panel: 'mock' }); + + const mockContext = createMockContext(); + RetrieveLogFile.apply(mockContext as unknown as import('../../Context.js').Context); + + const lastCall = mockRegisterCommand.mock.calls[mockRegisterCommand.mock.calls.length - 1]; + await lastCall[1](); + + // Verify the path passed to createView + const createViewCall = mockCreateView.mock.calls[0]; + const logFilePath = createViewCall[2]; + + expect(logFilePath).toBe('/my/project/.sfdx/tools/debug/logs/test-log-123.log'); + }); + + it('should append .log extension to fileId', async () => { + mockPickOrReturn.mockResolvedValue('/workspace'); + mockGetLogFiles.mockResolvedValue([ + { + Id: 'myLogId', + LogUser: { Name: 'User' }, + Operation: 'Op', + LogLength: 1024, + DurationMilliseconds: 100, + StartTime: '2024-01-01T00:00:00.000Z', + Status: 'Success', + }, + ]); + mockQuickPickPick.mockResolvedValue([{ logId: 'myLogId' }]); + mockExistsSync.mockReturnValue(true); + mockCreateView.mockResolvedValue({ panel: 'mock' }); + + const mockContext = createMockContext(); + RetrieveLogFile.apply(mockContext as unknown as import('../../Context.js').Context); + + const lastCall = mockRegisterCommand.mock.calls[mockRegisterCommand.mock.calls.length - 1]; + await lastCall[1](); + + const createViewCall = mockCreateView.mock.calls[0]; + const logFilePath = createViewCall[2]; + + expect(logFilePath).toContain('myLogId.log'); + }); + }); + + describe('writeLogFile', () => { + it('should call GetLogFile.apply when file does not exist', async () => { + mockPickOrReturn.mockResolvedValue('/test/ws'); + mockGetLogFiles.mockResolvedValue([ + { + Id: 'download-me', + LogUser: { Name: 'User' }, + Operation: 'Op', + LogLength: 1024, + DurationMilliseconds: 100, + StartTime: '2024-01-01T00:00:00.000Z', + Status: 'Success', + }, + ]); + mockQuickPickPick.mockResolvedValue([{ logId: 'download-me' }]); + mockExistsSync.mockReturnValue(false); + mockGetLogFile.mockResolvedValue(undefined); + mockCreateView.mockResolvedValue({ panel: 'mock' }); + + const mockContext = createMockContext(); + RetrieveLogFile.apply(mockContext as unknown as import('../../Context.js').Context); + + const lastCall = mockRegisterCommand.mock.calls[mockRegisterCommand.mock.calls.length - 1]; + await lastCall[1](); + + expect(mockGetLogFile).toHaveBeenCalledWith( + '/test/ws', + '/test/ws/.sfdx/tools/debug/logs', + 'download-me', + ); + }); + + it('should NOT call GetLogFile.apply when file exists', async () => { + mockPickOrReturn.mockResolvedValue('/test/ws'); + mockGetLogFiles.mockResolvedValue([ + { + Id: 'already-exists', + LogUser: { Name: 'User' }, + Operation: 'Op', + LogLength: 1024, + DurationMilliseconds: 100, + StartTime: '2024-01-01T00:00:00.000Z', + Status: 'Success', + }, + ]); + mockQuickPickPick.mockResolvedValue([{ logId: 'already-exists' }]); + mockExistsSync.mockReturnValue(true); + mockCreateView.mockResolvedValue({ panel: 'mock' }); + + const mockContext = createMockContext(); + RetrieveLogFile.apply(mockContext as unknown as import('../../Context.js').Context); + + const lastCall = mockRegisterCommand.mock.calls[mockRegisterCommand.mock.calls.length - 1]; + await lastCall[1](); + + expect(mockGetLogFile).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/lana/src/commands/__tests__/SwitchTimelineTheme.test.ts b/lana/src/commands/__tests__/SwitchTimelineTheme.test.ts new file mode 100644 index 00000000..8a5daff0 --- /dev/null +++ b/lana/src/commands/__tests__/SwitchTimelineTheme.test.ts @@ -0,0 +1,404 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +import { window } from 'vscode'; + +import { createMockContext } from '../../__tests__/helpers/test-builders.js'; +import { SwitchTimelineTheme } from '../SwitchTimelineTheme.js'; + +// Mock AppConfig +jest.mock('../../workspace/AppConfig.js', () => ({ + getConfig: jest.fn(), + updateConfig: jest.fn(), +})); + +// Mock LogView +jest.mock('../LogView.js', () => ({ + LogView: { + getCurrentView: jest.fn(), + }, +})); + +import { getConfig, updateConfig } from '../../workspace/AppConfig.js'; +import { LogView } from '../LogView.js'; + +const mockGetConfig = getConfig as jest.Mock; +const mockUpdateConfig = updateConfig as jest.Mock; +const mockGetCurrentView = LogView.getCurrentView as jest.Mock; + +describe('SwitchTimelineTheme', () => { + let mockQuickPick: { + items: Array<{ label: string; description?: string }>; + activeItems: Array<{ label: string }>; + placeholder: string; + show: jest.Mock; + hide: jest.Mock; + dispose: jest.Mock; + onDidChangeActive: jest.Mock; + onDidAccept: jest.Mock; + onDidHide: jest.Mock; + }; + + let onDidAcceptCallback: () => void; + let onDidHideCallback: () => void; + let onDidChangeActiveCallback: (items: Array<{ label: string }>) => void; + + beforeEach(() => { + mockQuickPick = { + items: [], + activeItems: [], + placeholder: '', + show: jest.fn(), + hide: jest.fn(), + dispose: jest.fn(), + onDidChangeActive: jest.fn((cb) => { + onDidChangeActiveCallback = cb; + return { dispose: jest.fn() }; + }), + onDidAccept: jest.fn((cb) => { + onDidAcceptCallback = cb; + return { dispose: jest.fn() }; + }), + onDidHide: jest.fn((cb) => { + onDidHideCallback = cb; + return { dispose: jest.fn() }; + }), + }; + (window.createQuickPick as jest.Mock).mockReturnValue(mockQuickPick); + + mockGetConfig.mockReturnValue({ + timeline: { + activeTheme: '50 Shades of Green', + customThemes: {}, + }, + }); + + mockUpdateConfig.mockResolvedValue(undefined); + mockGetCurrentView.mockReturnValue(null); + }); + + describe('getCommand', () => { + it('should return command with correct name', () => { + const mockContext = createMockContext(); + const command = SwitchTimelineTheme.getCommand( + mockContext as unknown as import('../../Context.js').Context, + ); + + expect(command.name).toBe('switchTimelineTheme'); + }); + + it('should return command with correct title', () => { + const mockContext = createMockContext(); + const command = SwitchTimelineTheme.getCommand( + mockContext as unknown as import('../../Context.js').Context, + ); + + expect(command.title).toBe('Log: Timeline Theme'); + }); + }); + + describe('theme list building', () => { + it('should include all preset themes', async () => { + const mockContext = createMockContext(); + const command = SwitchTimelineTheme.getCommand( + mockContext as unknown as import('../../Context.js').Context, + ); + + await command.run({} as never); + + const items = mockQuickPick.items; + expect(items.some((i) => i.label === '50 Shades of Green')).toBe(true); + expect(items.some((i) => i.label === 'Dracula')).toBe(true); + expect(items.some((i) => i.label === 'Nord')).toBe(true); + expect(items.some((i) => i.label === 'Monokai Pro')).toBe(true); + }); + + it('should include custom themes from config', async () => { + mockGetConfig.mockReturnValue({ + timeline: { + activeTheme: '50 Shades of Green', + customThemes: { + 'My Custom Theme': {}, + 'Another Theme': {}, + }, + }, + }); + + const mockContext = createMockContext(); + const command = SwitchTimelineTheme.getCommand( + mockContext as unknown as import('../../Context.js').Context, + ); + + await command.run({} as never); + + const items = mockQuickPick.items; + expect(items.some((i) => i.label === 'My Custom Theme')).toBe(true); + expect(items.some((i) => i.label === 'Another Theme')).toBe(true); + }); + + it('should sort themes alphabetically', async () => { + mockGetConfig.mockReturnValue({ + timeline: { + activeTheme: '50 Shades of Green', + customThemes: { + Zebra: {}, + Alpha: {}, + }, + }, + }); + + const mockContext = createMockContext(); + const command = SwitchTimelineTheme.getCommand( + mockContext as unknown as import('../../Context.js').Context, + ); + + await command.run({} as never); + + const labels = mockQuickPick.items.map((i) => i.label); + const sortedLabels = [...labels].sort(); + expect(labels).toEqual(sortedLabels); + }); + + it('should mark default theme with description', async () => { + const mockContext = createMockContext(); + const command = SwitchTimelineTheme.getCommand( + mockContext as unknown as import('../../Context.js').Context, + ); + + await command.run({} as never); + + const defaultItem = mockQuickPick.items.find((i) => i.label === '50 Shades of Green'); + expect(defaultItem?.description).toBe('default'); + }); + + it('should deduplicate themes when custom theme has same name as preset', async () => { + mockGetConfig.mockReturnValue({ + timeline: { + activeTheme: '50 Shades of Green', + customThemes: { + Dracula: {}, // Same name as preset + }, + }, + }); + + const mockContext = createMockContext(); + const command = SwitchTimelineTheme.getCommand( + mockContext as unknown as import('../../Context.js').Context, + ); + + await command.run({} as never); + + const draculaItems = mockQuickPick.items.filter((i) => i.label === 'Dracula'); + expect(draculaItems.length).toBe(1); + }); + }); + + describe('active theme selection', () => { + it('should set active item to current theme', async () => { + mockGetConfig.mockReturnValue({ + timeline: { + activeTheme: 'Nord', + customThemes: {}, + }, + }); + + const mockContext = createMockContext(); + const command = SwitchTimelineTheme.getCommand( + mockContext as unknown as import('../../Context.js').Context, + ); + + await command.run({} as never); + + expect(mockQuickPick.activeItems[0]?.label).toBe('Nord'); + }); + + it('should fall back to default when active theme not found', async () => { + mockGetConfig.mockReturnValue({ + timeline: { + activeTheme: 'NonExistent', + customThemes: {}, + }, + }); + + const mockContext = createMockContext(); + const command = SwitchTimelineTheme.getCommand( + mockContext as unknown as import('../../Context.js').Context, + ); + + await command.run({} as never); + + // activeItems won't be set if theme not found + expect(mockQuickPick.activeItems.length).toBe(0); + }); + }); + + describe('theme preview', () => { + it('should send theme change to webview on navigation', async () => { + const mockWebview = { + postMessage: jest.fn(), + }; + mockGetCurrentView.mockReturnValue({ webview: mockWebview }); + + const mockContext = createMockContext(); + const command = SwitchTimelineTheme.getCommand( + mockContext as unknown as import('../../Context.js').Context, + ); + + await command.run({} as never); + + // Simulate navigating to a theme + onDidChangeActiveCallback([{ label: 'Dracula' }]); + + expect(mockWebview.postMessage).toHaveBeenCalledWith({ + cmd: 'switchTimelineTheme', + payload: { activeTheme: 'Dracula' }, + }); + }); + + it('should not crash when no view is open', async () => { + mockGetCurrentView.mockReturnValue(null); + + const mockContext = createMockContext(); + const command = SwitchTimelineTheme.getCommand( + mockContext as unknown as import('../../Context.js').Context, + ); + + await command.run({} as never); + + expect(() => { + onDidChangeActiveCallback([{ label: 'Dracula' }]); + }).not.toThrow(); + }); + }); + + describe('theme selection', () => { + it('should update config on accept', async () => { + const mockContext = createMockContext(); + const command = SwitchTimelineTheme.getCommand( + mockContext as unknown as import('../../Context.js').Context, + ); + + await command.run({} as never); + + // Navigate to a theme + onDidChangeActiveCallback([{ label: 'Nord' }]); + // Accept selection + onDidAcceptCallback(); + + expect(mockUpdateConfig).toHaveBeenCalledWith('timeline.activeTheme', 'Nord'); + }); + + it('should hide picker on accept', async () => { + const mockContext = createMockContext(); + const command = SwitchTimelineTheme.getCommand( + mockContext as unknown as import('../../Context.js').Context, + ); + + await command.run({} as never); + + onDidChangeActiveCallback([{ label: 'Nord' }]); + // Need to await since onDidAccept is async + await onDidAcceptCallback(); + + expect(mockQuickPick.hide).toHaveBeenCalled(); + }); + }); + + describe('theme revert', () => { + it('should revert to original theme on hide without selection', async () => { + const mockWebview = { + postMessage: jest.fn(), + }; + mockGetCurrentView.mockReturnValue({ webview: mockWebview }); + + mockGetConfig.mockReturnValue({ + timeline: { + activeTheme: '50 Shades of Green', + customThemes: {}, + }, + }); + + const mockContext = createMockContext(); + const command = SwitchTimelineTheme.getCommand( + mockContext as unknown as import('../../Context.js').Context, + ); + + await command.run({} as never); + + // Navigate to different theme + onDidChangeActiveCallback([{ label: 'Nord' }]); + // Hide without accepting + onDidHideCallback(); + + // Should revert to original + expect(mockWebview.postMessage).toHaveBeenLastCalledWith({ + cmd: 'switchTimelineTheme', + payload: { activeTheme: '50 Shades of Green' }, + }); + }); + + it('should dispose picker on hide', async () => { + const mockContext = createMockContext(); + const command = SwitchTimelineTheme.getCommand( + mockContext as unknown as import('../../Context.js').Context, + ); + + await command.run({} as never); + onDidHideCallback(); + + expect(mockQuickPick.dispose).toHaveBeenCalled(); + }); + + it('should not revert when same theme is selected', async () => { + const mockWebview = { + postMessage: jest.fn(), + }; + mockGetCurrentView.mockReturnValue({ webview: mockWebview }); + + mockGetConfig.mockReturnValue({ + timeline: { + activeTheme: '50 Shades of Green', + customThemes: {}, + }, + }); + + const mockContext = createMockContext(); + const command = SwitchTimelineTheme.getCommand( + mockContext as unknown as import('../../Context.js').Context, + ); + + await command.run({} as never); + + // Navigate to same theme + onDidChangeActiveCallback([{ label: '50 Shades of Green' }]); + // Accept and hide + onDidAcceptCallback(); + mockWebview.postMessage.mockClear(); + onDidHideCallback(); + + // Should not send revert message (theme unchanged) + expect(mockWebview.postMessage).not.toHaveBeenCalled(); + }); + }); + + describe('apply', () => { + it('should register command with context', () => { + const mockContext = createMockContext(); + + SwitchTimelineTheme.apply(mockContext as unknown as import('../../Context.js').Context); + + expect(mockContext.context.subscriptions.length).toBe(1); + }); + + it('should output registration message', () => { + const mockContext = createMockContext(); + + SwitchTimelineTheme.apply(mockContext as unknown as import('../../Context.js').Context); + + expect(mockContext.display.output).toHaveBeenCalledWith( + "Registered command 'Lana: Timeline Theme'", + ); + }); + }); +}); diff --git a/lana/src/folding/__tests__/RawLogFoldingProvider.test.ts b/lana/src/folding/__tests__/RawLogFoldingProvider.test.ts new file mode 100644 index 00000000..baed0682 --- /dev/null +++ b/lana/src/folding/__tests__/RawLogFoldingProvider.test.ts @@ -0,0 +1,327 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +import { FoldingRangeKind, languages } from 'vscode'; + +import { + createMockApexLog, + createMockContext, + createMockLogEvent, +} from '../../__tests__/helpers/test-builders.js'; +import { createMockTextDocument } from '../../__tests__/mocks/vscode.js'; +import { LogEventCache } from '../../cache/LogEventCache.js'; +import { RawLogFoldingProvider } from '../RawLogFoldingProvider.js'; + +// Mock LogEventCache +jest.mock('../../cache/LogEventCache.js', () => ({ + LogEventCache: { + getApexLog: jest.fn(), + }, +})); + +const mockGetApexLog = LogEventCache.getApexLog as jest.Mock; + +describe('RawLogFoldingProvider', () => { + let provider: RawLogFoldingProvider; + + beforeEach(() => { + provider = new RawLogFoldingProvider(); + mockGetApexLog.mockReset(); + }); + + describe('provideFoldingRanges', () => { + describe('timestamp mapping', () => { + it('should extract timestamps from log lines', async () => { + const lines = [ + '09:45:31.888 (1000)|METHOD_ENTRY', + '09:45:31.889 (2000)|STATEMENT_EXECUTE', + '09:45:31.890 (3000)|METHOD_EXIT', + ]; + const doc = createMockTextDocument({ lines, uri: '/test/file.log' }); + + const event = createMockLogEvent({ + timestamp: 1000, + exitStamp: 3000, + children: [], + }); + mockGetApexLog.mockResolvedValueOnce(createMockApexLog({ children: [event] })); + + const ranges = await provider.provideFoldingRanges(doc, {} as never); + + expect(ranges.length).toBe(1); + expect(ranges[0]?.start).toBe(0); + expect(ranges[0]?.end).toBe(2); + }); + + it('should handle lines without timestamps', async () => { + const lines = [ + '09:45:31.888 (1000)|METHOD_ENTRY', + 'Some non-timestamp line', + '09:45:31.890 (2000)|METHOD_EXIT', + ]; + const doc = createMockTextDocument({ lines, uri: '/test/file.log' }); + + const event = createMockLogEvent({ + timestamp: 1000, + exitStamp: 2000, + children: [], + }); + mockGetApexLog.mockResolvedValueOnce(createMockApexLog({ children: [event] })); + + const ranges = await provider.provideFoldingRanges(doc, {} as never); + + expect(ranges.length).toBe(1); + expect(ranges[0]?.start).toBe(0); + expect(ranges[0]?.end).toBe(2); + }); + + it('should use first occurrence for duplicate timestamps', async () => { + const lines = [ + '09:45:31.888 (1000)|METHOD_ENTRY', + '09:45:31.888 (1000)|ANOTHER_EVENT', + '09:45:31.890 (2000)|METHOD_EXIT', + ]; + const doc = createMockTextDocument({ lines, uri: '/test/file.log' }); + + const event = createMockLogEvent({ + timestamp: 1000, + exitStamp: 2000, + children: [], + }); + mockGetApexLog.mockResolvedValueOnce(createMockApexLog({ children: [event] })); + + const ranges = await provider.provideFoldingRanges(doc, {} as never); + + // Should map to first occurrence (line 0) + expect(ranges[0]?.start).toBe(0); + }); + }); + + describe('folding range creation', () => { + it('should create folding range for event with exitStamp', async () => { + const lines = [ + '09:45:31.888 (1000)|METHOD_ENTRY', + '09:45:31.889 (1500)|STATEMENT_EXECUTE', + '09:45:31.890 (2000)|METHOD_EXIT', + ]; + const doc = createMockTextDocument({ lines, uri: '/test/file.log' }); + + const event = createMockLogEvent({ + timestamp: 1000, + exitStamp: 2000, + children: [], + }); + mockGetApexLog.mockResolvedValueOnce(createMockApexLog({ children: [event] })); + + const ranges = await provider.provideFoldingRanges(doc, {} as never); + + expect(ranges.length).toBe(1); + expect(ranges[0]?.kind).toBe(FoldingRangeKind.Region); + }); + + it('should not create folding range when exitStamp equals timestamp', async () => { + const lines = ['09:45:31.888 (1000)|METHOD_ENTRY']; + const doc = createMockTextDocument({ lines, uri: '/test/file.log' }); + + const event = createMockLogEvent({ + timestamp: 1000, + exitStamp: 1000, + children: [], + }); + mockGetApexLog.mockResolvedValueOnce(createMockApexLog({ children: [event] })); + + const ranges = await provider.provideFoldingRanges(doc, {} as never); + + expect(ranges.length).toBe(0); + }); + + it('should not create folding range when exitStamp is null', async () => { + const lines = ['09:45:31.888 (1000)|METHOD_ENTRY']; + const doc = createMockTextDocument({ lines, uri: '/test/file.log' }); + + const event = createMockLogEvent({ + timestamp: 1000, + exitStamp: null, + children: [], + }); + mockGetApexLog.mockResolvedValueOnce(createMockApexLog({ children: [event] })); + + const ranges = await provider.provideFoldingRanges(doc, {} as never); + + expect(ranges.length).toBe(0); + }); + + it('should not create folding range when end line is not after start line', async () => { + const lines = ['09:45:31.888 (2000)|METHOD_EXIT', '09:45:31.888 (1000)|METHOD_ENTRY']; + const doc = createMockTextDocument({ lines, uri: '/test/file.log' }); + + // Event with timestamps in reverse order in document + const event = createMockLogEvent({ + timestamp: 1000, + exitStamp: 2000, + children: [], + }); + mockGetApexLog.mockResolvedValueOnce(createMockApexLog({ children: [event] })); + + const ranges = await provider.provideFoldingRanges(doc, {} as never); + + // Should not create range since endLine (1) is not > startLine (0) + // Actually timestamps map: 1000->line1, 2000->line0 + // So start=1, end=0, which is invalid + expect(ranges.length).toBe(0); + }); + }); + + describe('nested events', () => { + it('should create folding ranges for nested events', async () => { + const lines = [ + '09:45:31.888 (1000)|CODE_UNIT_STARTED', + '09:45:31.889 (1500)|METHOD_ENTRY', + '09:45:31.890 (2000)|METHOD_EXIT', + '09:45:31.891 (3000)|CODE_UNIT_FINISHED', + ]; + const doc = createMockTextDocument({ lines, uri: '/test/file.log' }); + + const childEvent = createMockLogEvent({ + timestamp: 1500, + exitStamp: 2000, + children: [], + }); + const parentEvent = createMockLogEvent({ + timestamp: 1000, + exitStamp: 3000, + children: [childEvent], + }); + mockGetApexLog.mockResolvedValueOnce(createMockApexLog({ children: [parentEvent] })); + + const ranges = await provider.provideFoldingRanges(doc, {} as never); + + expect(ranges.length).toBe(2); + // Parent range + expect(ranges.some((r) => r.start === 0 && r.end === 3)).toBe(true); + // Child range + expect(ranges.some((r) => r.start === 1 && r.end === 2)).toBe(true); + }); + + it('should handle deeply nested events', async () => { + const lines = [ + '09:45:31.888 (1000)|LEVEL1_START', + '09:45:31.889 (2000)|LEVEL2_START', + '09:45:31.890 (3000)|LEVEL3_START', + '09:45:31.891 (4000)|LEVEL3_END', + '09:45:31.892 (5000)|LEVEL2_END', + '09:45:31.893 (6000)|LEVEL1_END', + ]; + const doc = createMockTextDocument({ lines, uri: '/test/file.log' }); + + const level3 = createMockLogEvent({ + timestamp: 3000, + exitStamp: 4000, + children: [], + }); + const level2 = createMockLogEvent({ + timestamp: 2000, + exitStamp: 5000, + children: [level3], + }); + const level1 = createMockLogEvent({ + timestamp: 1000, + exitStamp: 6000, + children: [level2], + }); + mockGetApexLog.mockResolvedValueOnce(createMockApexLog({ children: [level1] })); + + const ranges = await provider.provideFoldingRanges(doc, {} as never); + + expect(ranges.length).toBe(3); + }); + + it('should handle sibling events', async () => { + const lines = [ + '09:45:31.888 (1000)|METHOD1_ENTRY', + '09:45:31.889 (2000)|METHOD1_EXIT', + '09:45:31.890 (3000)|METHOD2_ENTRY', + '09:45:31.891 (4000)|METHOD2_EXIT', + ]; + const doc = createMockTextDocument({ lines, uri: '/test/file.log' }); + + const method1 = createMockLogEvent({ + timestamp: 1000, + exitStamp: 2000, + children: [], + }); + const method2 = createMockLogEvent({ + timestamp: 3000, + exitStamp: 4000, + children: [], + }); + mockGetApexLog.mockResolvedValueOnce(createMockApexLog({ children: [method1, method2] })); + + const ranges = await provider.provideFoldingRanges(doc, {} as never); + + expect(ranges.length).toBe(2); + expect(ranges.some((r) => r.start === 0 && r.end === 1)).toBe(true); + expect(ranges.some((r) => r.start === 2 && r.end === 3)).toBe(true); + }); + }); + + describe('edge cases', () => { + it('should return empty array when apexLog is null', async () => { + const doc = createMockTextDocument({ lines: [], uri: '/test/file.log' }); + mockGetApexLog.mockResolvedValueOnce(null); + + const ranges = await provider.provideFoldingRanges(doc, {} as never); + + expect(ranges).toEqual([]); + }); + + it('should return empty array for empty log', async () => { + const doc = createMockTextDocument({ lines: [], uri: '/test/file.log' }); + mockGetApexLog.mockResolvedValueOnce(createMockApexLog({ children: [] })); + + const ranges = await provider.provideFoldingRanges(doc, {} as never); + + expect(ranges).toEqual([]); + }); + + it('should handle events with timestamps not found in document', async () => { + const lines = ['09:45:31.888 (1000)|METHOD_ENTRY']; + const doc = createMockTextDocument({ lines, uri: '/test/file.log' }); + + const event = createMockLogEvent({ + timestamp: 9999, // Not in document + exitStamp: 10000, + children: [], + }); + mockGetApexLog.mockResolvedValueOnce(createMockApexLog({ children: [event] })); + + const ranges = await provider.provideFoldingRanges(doc, {} as never); + + expect(ranges).toEqual([]); + }); + }); + }); + + describe('apply', () => { + it('should register folding range provider for apexlog', () => { + const mockContext = createMockContext(); + + RawLogFoldingProvider.apply(mockContext as unknown as import('../../Context.js').Context); + + expect(languages.registerFoldingRangeProvider).toHaveBeenCalledTimes(1); + expect(languages.registerFoldingRangeProvider).toHaveBeenCalledWith( + [{ scheme: 'file', language: 'apexlog' }], + expect.any(RawLogFoldingProvider), + ); + }); + + it('should add disposable to context subscriptions', () => { + const mockContext = createMockContext(); + + RawLogFoldingProvider.apply(mockContext as unknown as import('../../Context.js').Context); + + expect(mockContext.context.subscriptions.length).toBe(1); + }); + }); +}); diff --git a/lana/src/salesforce/__tests__/ApexVisitor.test.ts b/lana/src/salesforce/__tests__/ApexVisitor.test.ts index d3167397..8397c960 100644 --- a/lana/src/salesforce/__tests__/ApexVisitor.test.ts +++ b/lana/src/salesforce/__tests__/ApexVisitor.test.ts @@ -16,6 +16,38 @@ describe('ApexVisitor', () => { }); describe('visitClassDeclaration', () => { + it('should use empty string when ident.text is null', () => { + const ctx = { + id: () => ({ + text: null, + start: { charPositionInLine: 5 }, + }), + children: [], + start: { line: 1 }, + }; + visitor.visitChildren = jest.fn().mockReturnValue({ children: [] }); + + const node = visitor.visitClassDeclaration(ctx as any); + + expect(node.name).toBe(''); + }); + + it('should use 0 when charPositionInLine is null', () => { + const ctx = { + id: () => ({ + text: 'MyClass', + start: { charPositionInLine: null }, + }), + children: [], + start: { line: 1 }, + }; + visitor.visitChildren = jest.fn().mockReturnValue({ children: [] }); + + const node = visitor.visitClassDeclaration(ctx as any); + + expect(node.idCharacter).toBe(0); + }); + it('should return class node with name and children', () => { const ctx = { id: () => ({ @@ -78,6 +110,44 @@ describe('ApexVisitor', () => { }); describe('visitMethodDeclaration', () => { + it('should use empty string when ident.text is null', () => { + const ctx = { + id: () => ({ + text: null, + start: { charPositionInLine: 5 }, + }), + children: [], + formalParameters: () => ({ + formalParameterList: () => undefined, + }), + start: { line: 1 }, + }; + visitor.visitChildren = jest.fn().mockReturnValue({ children: [] }); + + const node = visitor.visitMethodDeclaration(ctx as any); + + expect(node.name).toBe(''); + }); + + it('should use 0 when charPositionInLine is null', () => { + const ctx = { + id: () => ({ + text: 'myMethod', + start: { charPositionInLine: null }, + }), + children: [], + formalParameters: () => ({ + formalParameterList: () => undefined, + }), + start: { line: 1 }, + }; + visitor.visitChildren = jest.fn().mockReturnValue({ children: [] }); + + const node = visitor.visitMethodDeclaration(ctx as any); + + expect(node.idCharacter).toBe(0); + }); + it('should return method node with name, params, and line', () => { const ctx = { id: () => ({ @@ -127,6 +197,42 @@ describe('ApexVisitor', () => { }); describe('visitConstructorDeclaration', () => { + it('should use empty string when constructorName.text is null', () => { + const ctx = { + qualifiedName: () => ({ + id: () => [{ text: null }], + }), + children: [], + formalParameters: () => ({ + formalParameterList: () => undefined, + }), + start: { line: 1, charPositionInLine: 5 }, + }; + visitor.visitChildren = jest.fn().mockReturnValue({ children: [] }); + + const node = visitor.visitConstructorDeclaration(ctx as any); + + expect(node.name).toBe(''); + }); + + it('should use 0 when start.charPositionInLine is null', () => { + const ctx = { + qualifiedName: () => ({ + id: () => [{ text: 'MyConstructor' }], + }), + children: [], + formalParameters: () => ({ + formalParameterList: () => undefined, + }), + start: { line: 1, charPositionInLine: null }, + }; + visitor.visitChildren = jest.fn().mockReturnValue({ children: [] }); + + const node = visitor.visitConstructorDeclaration(ctx as any); + + expect(node.idCharacter).toBe(0); + }); + it('should return constructor node with name, params, and line', () => { const ctx = { qualifiedName: () => ({ @@ -210,4 +316,147 @@ describe('ApexVisitor', () => { expect(visitor.visitErrorNode({} as any)).toEqual({}); }); }); + + describe('visit', () => { + it('should return empty object when ctx is null', () => { + expect(visitor.visit(null as any)).toEqual({}); + }); + + it('should return empty object when ctx is undefined', () => { + expect(visitor.visit(undefined as any)).toEqual({}); + }); + + it('should call accept on context when ctx exists', () => { + const mockAccept = jest.fn().mockReturnValue({ nature: 'Method', name: 'test' }); + const ctx = { accept: mockAccept }; + + const result = visitor.visit(ctx as any); + + expect(mockAccept).toHaveBeenCalledWith(visitor); + expect(result).toEqual({ nature: 'Method', name: 'test' }); + }); + }); + + describe('visitChildren', () => { + it('should skip null nodes returned from visit', () => { + const ctx = { + childCount: 2, + getChild: jest.fn().mockImplementation((index: number) => ({ + accept: jest.fn().mockReturnValue(index === 0 ? null : { nature: 'Method', name: 'foo' }), + })), + }; + + const result = visitor.visitChildren(ctx as any); + + expect(result.children).toHaveLength(1); + expect(result.children![0]).toEqual({ nature: 'Method', name: 'foo' }); + }); + + it('should skip undefined nodes returned from visit', () => { + const ctx = { + childCount: 2, + getChild: jest.fn().mockImplementation((index: number) => ({ + accept: jest + .fn() + .mockReturnValue(index === 0 ? undefined : { nature: 'Method', name: 'bar' }), + })), + }; + + const result = visitor.visitChildren(ctx as any); + + expect(result.children).toHaveLength(1); + expect(result.children![0]).toEqual({ nature: 'Method', name: 'bar' }); + }); + + it('should process multiple valid nodes', () => { + const ctx = { + childCount: 3, + getChild: jest.fn().mockImplementation((index: number) => ({ + accept: jest.fn().mockReturnValue({ nature: 'Method', name: `method${index}` }), + })), + }; + + const result = visitor.visitChildren(ctx as any); + + expect(result.children).toHaveLength(3); + }); + + it('should flatten children from non-anon nodes (nodes without nature)', () => { + // A node without 'nature' should have its children extracted + const ctx = { + childCount: 1, + getChild: jest.fn().mockReturnValue({ + accept: jest.fn().mockReturnValue({ + // No nature property - this is a "non-anon" wrapper node + children: [ + { nature: 'Method', name: 'nested1' }, + { nature: 'Method', name: 'nested2' }, + ], + }), + }), + }; + + const result = visitor.visitChildren(ctx as any); + + // The children should be flattened + expect(result.children).toHaveLength(2); + expect(result.children![0]).toEqual({ nature: 'Method', name: 'nested1' }); + expect(result.children![1]).toEqual({ nature: 'Method', name: 'nested2' }); + }); + + it('should handle non-anon nodes with empty children array', () => { + const ctx = { + childCount: 1, + getChild: jest.fn().mockReturnValue({ + accept: jest.fn().mockReturnValue({ + // No nature, empty children + children: [], + }), + }), + }; + + const result = visitor.visitChildren(ctx as any); + + expect(result.children).toHaveLength(0); + }); + + it('should handle non-anon nodes with no children property', () => { + const ctx = { + childCount: 1, + getChild: jest.fn().mockReturnValue({ + accept: jest.fn().mockReturnValue({ + // No nature, no children property + name: 'wrapper', + }), + }), + }; + + const result = visitor.visitChildren(ctx as any); + + // Node is not anon (no nature) and has no children, so nothing added + expect(result.children).toHaveLength(0); + }); + + it('should handle mix of anon and non-anon nodes', () => { + const ctx = { + childCount: 2, + getChild: jest.fn().mockImplementation((index: number) => ({ + accept: jest.fn().mockReturnValue( + index === 0 + ? { nature: 'Class', name: 'MyClass' } // Anon node (has nature) + : { + // Non-anon node (no nature) + children: [{ nature: 'Method', name: 'nested' }], + }, + ), + })), + }; + + const result = visitor.visitChildren(ctx as any); + + expect(result.children).toHaveLength(2); + expect(result.children![0]).toEqual({ nature: 'Class', name: 'MyClass' }); + expect(result.children![1]).toEqual({ nature: 'Method', name: 'nested' }); + }); + }); });