diff --git a/.gitignore b/.gitignore index 626818bb5..bbf76b856 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ .DS_Store node_modules +.pnpm-store/ dist/ build/ out/ diff --git a/.prettierignore b/.prettierignore index b1e0c132d..44b32ea4a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,7 @@ build/ out/ pnpm-lock.yaml + +# Each live worktree is a second copy of the repo, inside it. Without this the +# gate checks every one of them, and fails on a branch you cannot fix from here. +.claude/worktrees/ diff --git a/jest.config.js b/jest.config.js index 434bb33ba..e632d81c8 100644 --- a/jest.config.js +++ b/jest.config.js @@ -46,6 +46,12 @@ export default { // Stylesheet imports have no transform here; the `.js` rule above runs first, so the // `*.css.ts` style modules are unaffected. '\\.s?css$': '/src/__tests__/mocks/styleStub.ts', + // jsdom's ElementInternals has no setFormValue, so a form-associated element fails on + // its first update, and vscode-icon warns on every connect about the missing codicon + // stylesheet. Every importer wants the side effect only. vscode-single-select is the + // exception: VsSelect extends the class and reads its styles, so it must stay real. + '^#vscode-elements/(?!vscode-single-select\\.js$)': + '/src/__tests__/mocks/vscodeElementStub.ts', }, transformIgnorePatterns: [ // allow transformation of pixi.js and its dependencies diff --git a/lana/src/__tests__/Main.test.ts b/lana/src/__tests__/Main.test.ts index 1ef26067c..c0422f57c 100644 --- a/lana/src/__tests__/Main.test.ts +++ b/lana/src/__tests__/Main.test.ts @@ -1,7 +1,7 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -import { beforeEach, describe, expect, it } from '@jest/globals'; +import { describe, expect, it } from '@jest/globals'; import { createMockExtensionContext } from './mocks/vscode.js'; import { Context } from '../Context.js'; @@ -22,10 +22,6 @@ const mockDisposeServices = disposeServices as jest.Mock; const mockInitServices = initServices as jest.Mock; describe('Main', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - it('activates without initializing Salesforce Services', () => { const extensionContext = createMockExtensionContext(); diff --git a/lana/src/__tests__/helpers/test-builders.ts b/lana/src/__tests__/helpers/test-builders.ts index 8ea2cce15..b8630024d 100644 --- a/lana/src/__tests__/helpers/test-builders.ts +++ b/lana/src/__tests__/helpers/test-builders.ts @@ -8,7 +8,13 @@ import type { ApexLog, LogEvent } from 'apex-log-parser'; -import { createMockExtensionContext, type MockExtensionContext } from '../mocks/vscode.js'; +import type { Context } from '../../Context.js'; + +import { + commands, + createMockExtensionContext, + type MockExtensionContext, +} from '../mocks/vscode.js'; /** * Partial type for creating mock LogEvent objects. @@ -200,3 +206,25 @@ export function createMockContext(overrides: Partial = {}): MockCon return { ...base, ...overrides }; } + +/** + * A mock where the code under test wants the real Context. The mock carries the + * fields a command reaches for and nothing else, so the compiler cannot see it as + * one without being told. + */ +export function asContext(mock: MockContext): Context { + return mock as unknown as Context; +} + +/** + * The handler the code under test registered last. A command registers on apply, + * so the last call is the one the case just made. Every handler is `Command.run`, + * which is why one signature covers them all. + */ +export function lastRegisteredCommand(): (...args: unknown[]) => Promise { + const handler = commands.registerCommand.mock.calls.at(-1)?.[1]; + if (!handler) { + throw new Error('no command registered — did the case call apply()?'); + } + return handler as (...args: unknown[]) => Promise; +} diff --git a/lana/src/__tests__/log-utils.test.ts b/lana/src/__tests__/log-utils.test.ts index e781caa78..a6e0534df 100644 --- a/lana/src/__tests__/log-utils.test.ts +++ b/lana/src/__tests__/log-utils.test.ts @@ -8,154 +8,55 @@ 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'); - }); + // The unit steps at 1s and at 60s, so the rows either side of each are the ones + // that matter. + it.each([ + [0, '0.00ms'], + [500_000, '0.50ms'], + [1_000_000, '1.00ms'], + [999_000_000, '999.00ms'], + [1_000_000_000, '1.00s'], + [1_500_000_000, '1.50s'], + [30_000_000_000, '30.00s'], + [59_990_000_000, '59.99s'], + [60_000_000_000, '1m 0.00s'], + [90_000_000_000, '1m 30.00s'], + [150_000_000_000, '2m 30.00s'], + [600_000_000_000, '10m 0.00s'], + ])('formats %d ns as %s', (ns, expected) => { + expect(formatDuration(ns)).toBe(expected); }); - 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'); - }); + it.each([ + [123_456_789, '123.46ms'], + [61_234_567_890, '1m 1.23s'], + ])('rounds %d ns to two decimal places, giving %s', (ns, expected) => { + expect(formatDuration(ns)).toBe(expected); }); }); 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(); - }); + it.each([ + ['09:45:31.888 (38889007737)|METHOD_ENTRY', '38889007737'], + ['12:00:00.000 (1000)|CODE_UNIT_STARTED', '1000'], + ['23:59:59.999 (999999999999)|SOQL_EXECUTE_BEGIN', '999999999999'], + ['00:00:00.001 (1)|DML_BEGIN', '1'], + // One decimal place, not three. + ['10:30:45.1 (12345)|EXECUTION_STARTED', '12345'], + ])('captures the nanoseconds of %s', (line, expected) => { + expect(line.match(TIMESTAMP_REGEX)?.[1]).toBe(expected); }); - 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(); - }); + it.each([ + 'This is just some text', + // A single-digit hour. + '9:45:31.888 (38889007737)|METHOD_ENTRY', + '09:45:31.888 (38889007737) METHOD_ENTRY', + // Anchored, so a timestamp that does not start the line is not one. + 'prefix 09:45:31.888 (38889007737)|METHOD_ENTRY', + '', + ])('does not match %p', (line) => { + expect(line.match(TIMESTAMP_REGEX)).toBeNull(); }); }); diff --git a/lana/src/cache/__tests__/LogEventCache.test.ts b/lana/src/cache/__tests__/LogEventCache.test.ts index 3d569fe35..480f66420 100644 --- a/lana/src/cache/__tests__/LogEventCache.test.ts +++ b/lana/src/cache/__tests__/LogEventCache.test.ts @@ -6,6 +6,7 @@ import { Uri, workspace } from 'vscode'; import { createMockApexLog, + asContext, createMockContext, createMockDisplay, createMockLogEvent, @@ -396,7 +397,7 @@ describe('LogEventCache', () => { it('should register onDidCloseTextDocument listener', () => { const mockContext = createMockContext(); - LogEventCache.apply(mockContext as unknown as import('../../Context.js').Context); + LogEventCache.apply(asContext(mockContext)); expect(workspace.onDidCloseTextDocument).toHaveBeenCalledTimes(1); expect(mockContext.context.subscriptions.length).toBe(1); @@ -418,7 +419,7 @@ describe('LogEventCache', () => { }); const mockContext = createMockContext(); - LogEventCache.apply(mockContext as unknown as import('../../Context.js').Context); + LogEventCache.apply(asContext(mockContext)); // Simulate closing an apexlog document closeCallback!({ @@ -446,7 +447,7 @@ describe('LogEventCache', () => { }); const mockContext = createMockContext(); - LogEventCache.apply(mockContext as unknown as import('../../Context.js').Context); + LogEventCache.apply(asContext(mockContext)); // A log saved as .trace or pasted into an untitled buffer never gets the apexlog // language, but the decoration provider still parses it, so it must still clear. diff --git a/lana/src/commands/__tests__/Command.test.ts b/lana/src/commands/__tests__/Command.test.ts index 41263160d..292ee3c41 100644 --- a/lana/src/commands/__tests__/Command.test.ts +++ b/lana/src/commands/__tests__/Command.test.ts @@ -1,26 +1,21 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -import { beforeEach, describe, expect, it } from '@jest/globals'; +import { describe, expect, it } from '@jest/globals'; import { commands } from 'vscode'; -import type { Context } from '../../Context.js'; -import { createMockContext } from '../../__tests__/helpers/test-builders.js'; +import { asContext, createMockContext } from '../../__tests__/helpers/test-builders.js'; import { Command } from '../Command.js'; const mockRegisterCommand = commands.registerCommand as jest.Mock; describe('Command', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - it('returns what the handler returns', async () => { const context = createMockContext(); const command = new Command( 'aCommand', 'A Command', - context as unknown as Context, + asContext(context), 'Error running the command', () => Promise.resolve('a result'), ); @@ -34,7 +29,7 @@ describe('Command', () => { const command = new Command( 'aCommand', 'A Command', - context as unknown as Context, + asContext(context), 'Error running the command', () => Promise.reject(new Error('it broke')), ); @@ -50,7 +45,7 @@ describe('Command', () => { const command = new Command( 'aCommand', 'A Command', - context as unknown as Context, + asContext(context), 'Error running the command', () => { throw new Error('it broke'); @@ -65,12 +60,8 @@ describe('Command', () => { it('registers the guarded handler, not the raw one', async () => { const context = createMockContext(); - new Command( - 'aCommand', - 'A Command', - context as unknown as Context, - 'Error running the command', - () => Promise.reject(new Error('it broke')), + new Command('aCommand', 'A Command', asContext(context), 'Error running the command', () => + Promise.reject(new Error('it broke')), ).register(); const [name, registered] = mockRegisterCommand.mock.calls[0] as [ diff --git a/lana/src/commands/__tests__/LogView.test.ts b/lana/src/commands/__tests__/LogView.test.ts index 1afb75652..934870fc8 100644 --- a/lana/src/commands/__tests__/LogView.test.ts +++ b/lana/src/commands/__tests__/LogView.test.ts @@ -3,7 +3,7 @@ */ import { describe, expect, it } from '@jest/globals'; -import { createMockContext } from '../../__tests__/helpers/test-builders.js'; +import { asContext, createMockContext } from '../../__tests__/helpers/test-builders.js'; import { Uri, workspace } from '../../__tests__/mocks/vscode.js'; import { getConfig } from '../../workspace/AppConfig.js'; import { WebView } from '../../display/WebView.js'; @@ -75,7 +75,7 @@ describe('LogView', () => { ); await LogView.createView( - createMockContext() as unknown as import('../../Context.js').Context, + asContext(createMockContext()), Promise.resolve(), Uri.parse('memfs:/repository/logs/virtual.log'), 'log body', @@ -109,12 +109,7 @@ describe('LogView', () => { const context = createMockContext(); const logUri = Uri.parse('memfs:/repository/logs/virtual.log'); - await LogView.createView( - context as unknown as import('../../Context.js').Context, - Promise.resolve(), - logUri, - 'log body', - ); + await LogView.createView(asContext(context), Promise.resolve(), logUri, 'log body'); // createView must resolve and rewrite the bundled index.html. It read that // file through a service needing another extension's initialisation, so it // rejected before the webview had any content. @@ -147,7 +142,7 @@ describe('LogView', () => { new TextEncoder().encode(''), ); - await LogView.createView(createMockContext() as unknown as import('../../Context.js').Context); + await LogView.createView(asContext(createMockContext())); expect(mockReadFile).toHaveBeenCalledWith(Uri.parse('file:///test/extension/out/index.html')); expect(panel.webview.html).toContain('webview:/test/extension/out/bundle.js'); @@ -166,7 +161,7 @@ describe('LogView', () => { try { const context = createMockContext(); const failed = Promise.reject(new Error('org unreachable')); - await LogView.createView(context as unknown as import('../../Context.js').Context, failed); + await LogView.createView(asContext(context), failed); // No fetchLog is posted, so nothing here awaits the body. await new Promise((resolve) => setImmediate(resolve)); expect(unhandled).toEqual([]); @@ -185,9 +180,9 @@ describe('LogView', () => { mockApplyWebView.mockReturnValue(panel as unknown as import('vscode').WebviewPanel); mockReadFile.mockRejectedValue(new Error('ENOENT')); - await expect( - LogView.createView(createMockContext() as unknown as import('../../Context.js').Context), - ).rejects.toThrow('Could not read the log viewer at /test/extension/out/index.html: ENOENT'); + await expect(LogView.createView(asContext(createMockContext()))).rejects.toThrow( + 'Could not read the log viewer at /test/extension/out/index.html: ENOENT', + ); }); it('answers a request whose case throws, so the webview stops waiting', async () => { diff --git a/lana/src/commands/__tests__/RetrieveLogFile.test.ts b/lana/src/commands/__tests__/RetrieveLogFile.test.ts index bd89fec92..3e0fd9057 100644 --- a/lana/src/commands/__tests__/RetrieveLogFile.test.ts +++ b/lana/src/commands/__tests__/RetrieveLogFile.test.ts @@ -2,8 +2,12 @@ * Copyright (c) 2026 Certinia Inc. All rights reserved. */ import { beforeEach, describe, expect, it } from '@jest/globals'; -import { commands, Uri, window, workspace } from 'vscode'; -import { createMockContext } from '../../__tests__/helpers/test-builders.js'; +import { Uri, window, workspace } from 'vscode'; +import { + asContext, + createMockContext, + lastRegisteredCommand, +} from '../../__tests__/helpers/test-builders.js'; import { QuickPick } from '../../display/QuickPick.js'; import { ensureServicesAvailable, @@ -56,7 +60,6 @@ const mockListLogs = listLogs as jest.Mock; const mockGetLogBody = getLogBody as jest.Mock; const mockWriteFile = writeFile as jest.Mock; const mockCreateView = LogView.createView as jest.Mock; -const mockRegisterCommand = commands.registerCommand as jest.Mock; const mockWorkspace = workspace as unknown as { workspaceFolders: Array<{ uri: ReturnType; @@ -82,7 +85,6 @@ function retrieveLogPromise(): Promise { describe('RetrieveLogFile', () => { beforeEach(() => { - jest.clearAllMocks(); mockEnsureServicesAvailable.mockResolvedValue(true); mockFileOrFolderExists.mockResolvedValue(false); mockWorkspace.workspaceFolders = [ @@ -114,13 +116,12 @@ describe('RetrieveLogFile', () => { const settle = () => new Promise((resolve) => setImmediate(resolve)); - const command = (): (() => Promise) => - mockRegisterCommand.mock.calls[mockRegisterCommand.mock.calls.length - 1]?.[1]; + const command = () => lastRegisteredCommand(); it('closes the loading picker and says so when Salesforce cannot list the logs', async () => { mockListLogs.mockRejectedValue(new Error('no org connection')); const context = createMockContext(); - RetrieveLogFile.apply(context as unknown as import('../../Context.js').Context); + RetrieveLogFile.apply(asContext(context)); await command()(); @@ -133,7 +134,7 @@ describe('RetrieveLogFile', () => { it('cancels the log list when the user dismisses the picker', async () => { mockListLogs.mockReturnValue(new Promise(() => {})); const context = createMockContext(); - RetrieveLogFile.apply(context as unknown as import('../../Context.js').Context); + RetrieveLogFile.apply(asContext(context)); const running = command()(); await settle(); @@ -149,7 +150,7 @@ describe('RetrieveLogFile', () => { it('closes the loading picker and reports nothing when the user dismisses it', async () => { mockListLogs.mockReturnValue(new Promise(() => {})); const context = createMockContext(); - RetrieveLogFile.apply(context as unknown as import('../../Context.js').Context); + RetrieveLogFile.apply(asContext(context)); const running = command()(); await settle(); @@ -163,13 +164,13 @@ describe('RetrieveLogFile', () => { it('registers the command', () => { const context = createMockContext(); - RetrieveLogFile.apply(context as unknown as import('../../Context.js').Context); + RetrieveLogFile.apply(asContext(context)); expect(context.context.subscriptions).toHaveLength(1); }); it('lists logs through Salesforce Services', async () => { const context = createMockContext(); - RetrieveLogFile.apply(context as unknown as import('../../Context.js').Context); + RetrieveLogFile.apply(asContext(context)); await command()(); expect(mockEnsureServicesAvailable).toHaveBeenCalledWith(); expect(mockListLogs).toHaveBeenCalledWith(expect.any(AbortSignal)); @@ -179,7 +180,7 @@ describe('RetrieveLogFile', () => { mockListLogs.mockResolvedValue([log('selected-log')]); mockPick.mockResolvedValue([{ logId: 'selected-log' }]); const context = createMockContext(); - RetrieveLogFile.apply(context as unknown as import('../../Context.js').Context); + RetrieveLogFile.apply(asContext(context)); await command()(); expect(mockCreateView).toHaveBeenCalledWith( @@ -207,7 +208,7 @@ describe('RetrieveLogFile', () => { mockListLogs.mockResolvedValue([log('selected-log')]); mockPick.mockResolvedValue([{ logId: 'selected-log' }]); const context = createMockContext(); - RetrieveLogFile.apply(context as unknown as import('../../Context.js').Context); + RetrieveLogFile.apply(asContext(context)); await command()(); @@ -226,7 +227,7 @@ describe('RetrieveLogFile', () => { mockPick.mockResolvedValue([{ logId: 'cached-log' }]); mockFileOrFolderExists.mockResolvedValue(true); const context = createMockContext(); - RetrieveLogFile.apply(context as unknown as import('../../Context.js').Context); + RetrieveLogFile.apply(asContext(context)); await command()(); expect(mockGetLogBody).not.toHaveBeenCalled(); @@ -242,7 +243,7 @@ describe('RetrieveLogFile', () => { mockEnsureServicesAvailable.mockResolvedValue(false); mockWorkspace.workspaceFolders = []; const context = createMockContext(); - RetrieveLogFile.apply(context as unknown as import('../../Context.js').Context); + RetrieveLogFile.apply(asContext(context)); await command()(); expect(mockListLogs).not.toHaveBeenCalled(); @@ -254,7 +255,7 @@ describe('RetrieveLogFile', () => { mockPick.mockResolvedValue([{ logId: 'selected-log' }]); mockWriteFile.mockRejectedValue(new Error('read-only workspace')); const context = createMockContext(); - RetrieveLogFile.apply(context as unknown as import('../../Context.js').Context); + RetrieveLogFile.apply(asContext(context)); await command()(); expect(mockCreateView).toHaveBeenCalled(); @@ -276,7 +277,7 @@ describe('RetrieveLogFile', () => { return Promise.resolve([]); }); const context = createMockContext(); - RetrieveLogFile.apply(context as unknown as import('../../Context.js').Context); + RetrieveLogFile.apply(asContext(context)); await command()(); expect(items.map((item) => item.logId)).toEqual(['new', 'old']); }); @@ -299,7 +300,7 @@ describe('RetrieveLogFile', () => { return Promise.resolve([]); }); const context = createMockContext(); - RetrieveLogFile.apply(context as unknown as import('../../Context.js').Context); + RetrieveLogFile.apply(asContext(context)); await command()(); expect(description).toContain(expectedDuration); }); @@ -311,7 +312,7 @@ describe('RetrieveLogFile', () => { mockPick.mockResolvedValue([{ logId: 'denied' }]); mockGetLogBody.mockResolvedValue(response); const context = createMockContext(); - RetrieveLogFile.apply(context as unknown as import('../../Context.js').Context); + RetrieveLogFile.apply(asContext(context)); await command()(); await expect(retrieveLogPromise()).rejects.toThrow('Salesforce denied access'); }, diff --git a/lana/src/commands/__tests__/ShowInLogAnalysis.test.ts b/lana/src/commands/__tests__/ShowInLogAnalysis.test.ts index 8f1037947..993567a28 100644 --- a/lana/src/commands/__tests__/ShowInLogAnalysis.test.ts +++ b/lana/src/commands/__tests__/ShowInLogAnalysis.test.ts @@ -2,10 +2,12 @@ * Copyright (c) 2026 Certinia Inc. All rights reserved. */ import { beforeEach, describe, expect, it } from '@jest/globals'; -import { commands } from 'vscode'; -import type { Context } from '../../Context.js'; -import { createMockContext } from '../../__tests__/helpers/test-builders.js'; +import { + asContext, + createMockContext, + lastRegisteredCommand, +} from '../../__tests__/helpers/test-builders.js'; import { LogView } from '../LogView.js'; import { ShowInLogAnalysis } from '../ShowInLogAnalysis.js'; @@ -20,21 +22,18 @@ jest.mock('../LogView.js', () => ({ const mockCreateView = LogView.createView as jest.Mock; const mockGetCurrentView = LogView.getCurrentView as jest.Mock; -const mockRegisterCommand = commands.registerCommand as jest.Mock; describe('ShowInLogAnalysis', () => { beforeEach(() => { - jest.clearAllMocks(); mockGetCurrentView.mockReturnValue(undefined); mockCreateView.mockResolvedValue(undefined); }); - const command = (): ((args: unknown) => Promise) => - mockRegisterCommand.mock.calls[mockRegisterCommand.mock.calls.length - 1]?.[1]; + const command = () => lastRegisteredCommand(); it('opens the log at the timestamp', async () => { const context = createMockContext(); - ShowInLogAnalysis.apply(context as unknown as Context); + ShowInLogAnalysis.apply(asContext(context)); await command()({ timestamp: 42, filePath: 'memfs:/logs/a.log' }); @@ -46,7 +45,7 @@ describe('ShowInLogAnalysis', () => { it('reports a failure to open the log rather than failing silently', async () => { mockCreateView.mockRejectedValue(new Error('viewer is missing')); const context = createMockContext(); - ShowInLogAnalysis.apply(context as unknown as Context); + ShowInLogAnalysis.apply(asContext(context)); await expect( command()({ timestamp: 42, filePath: 'memfs:/logs/a.log' }), diff --git a/lana/src/commands/__tests__/ShowLogAnalysis.test.ts b/lana/src/commands/__tests__/ShowLogAnalysis.test.ts index 55aeb13f8..aa1820234 100644 --- a/lana/src/commands/__tests__/ShowLogAnalysis.test.ts +++ b/lana/src/commands/__tests__/ShowLogAnalysis.test.ts @@ -2,9 +2,13 @@ * Copyright (c) 2026 Certinia Inc. All rights reserved. */ import { beforeEach, describe, expect, it } from '@jest/globals'; -import { commands, Uri } from 'vscode'; +import { Uri } from 'vscode'; -import { createMockContext } from '../../__tests__/helpers/test-builders.js'; +import { + asContext, + createMockContext, + lastRegisteredCommand, +} from '../../__tests__/helpers/test-builders.js'; import { fileOrFolderExists } from '../../fs/workspaceFs.js'; import { LogView } from '../LogView.js'; import { ShowLogAnalysis } from '../ShowLogAnalysis.js'; @@ -14,21 +18,18 @@ jest.mock('../LogView.js', () => ({ LogView: { createView: jest.fn() } })); const mockFileOrFolderExists = fileOrFolderExists as jest.Mock; const mockCreateView = LogView.createView as jest.Mock; -const mockRegisterCommand = commands.registerCommand as jest.Mock; describe('ShowLogAnalysis', () => { beforeEach(() => { - jest.clearAllMocks(); mockFileOrFolderExists.mockResolvedValue(true); mockCreateView.mockResolvedValue(undefined); }); - const command = (): ((uri: unknown) => Promise) => - mockRegisterCommand.mock.calls[mockRegisterCommand.mock.calls.length - 1]?.[1]; + const command = () => lastRegisteredCommand(); it('opens the log passed to it', async () => { const context = createMockContext(); - ShowLogAnalysis.apply(context as unknown as import('../../Context.js').Context); + ShowLogAnalysis.apply(asContext(context)); await command()(Uri.parse('memfs:/logs/a.log')); @@ -39,7 +40,7 @@ describe('ShowLogAnalysis', () => { it('reports a failure to open the log rather than failing silently', async () => { mockCreateView.mockRejectedValue(new Error('viewer is missing')); const context = createMockContext(); - ShowLogAnalysis.apply(context as unknown as import('../../Context.js').Context); + ShowLogAnalysis.apply(asContext(context)); await expect(command()(Uri.parse('memfs:/logs/a.log'))).resolves.toBeUndefined(); diff --git a/lana/src/commands/__tests__/SwitchTimelineTheme.test.ts b/lana/src/commands/__tests__/SwitchTimelineTheme.test.ts index a4733b238..e4845cfb2 100644 --- a/lana/src/commands/__tests__/SwitchTimelineTheme.test.ts +++ b/lana/src/commands/__tests__/SwitchTimelineTheme.test.ts @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it } from '@jest/globals'; import { Uri, window } from 'vscode'; -import { createMockContext } from '../../__tests__/helpers/test-builders.js'; +import { asContext, createMockContext } from '../../__tests__/helpers/test-builders.js'; import { SwitchTimelineTheme } from '../SwitchTimelineTheme.js'; // Mock AppConfig @@ -82,18 +82,14 @@ describe('SwitchTimelineTheme', () => { describe('getCommand', () => { it('should return command with correct name', () => { const mockContext = createMockContext(); - const command = SwitchTimelineTheme.getCommand( - mockContext as unknown as import('../../Context.js').Context, - ); + const command = SwitchTimelineTheme.getCommand(asContext(mockContext)); 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, - ); + const command = SwitchTimelineTheme.getCommand(asContext(mockContext)); expect(command.title).toBe('Log: Timeline Theme'); }); @@ -103,9 +99,7 @@ describe('SwitchTimelineTheme', () => { throw new Error('config unavailable'); }); const mockContext = createMockContext(); - const command = SwitchTimelineTheme.getCommand( - mockContext as unknown as import('../../Context.js').Context, - ); + const command = SwitchTimelineTheme.getCommand(asContext(mockContext)); await expect(command.run(Uri.parse('memfs:/logs/a.log'))).resolves.toBeUndefined(); @@ -118,9 +112,7 @@ describe('SwitchTimelineTheme', () => { 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, - ); + const command = SwitchTimelineTheme.getCommand(asContext(mockContext)); await command.run({} as never); @@ -143,9 +135,7 @@ describe('SwitchTimelineTheme', () => { }); const mockContext = createMockContext(); - const command = SwitchTimelineTheme.getCommand( - mockContext as unknown as import('../../Context.js').Context, - ); + const command = SwitchTimelineTheme.getCommand(asContext(mockContext)); await command.run({} as never); @@ -170,9 +160,7 @@ describe('SwitchTimelineTheme', () => { }); const mockContext = createMockContext(); - const command = SwitchTimelineTheme.getCommand( - mockContext as unknown as import('../../Context.js').Context, - ); + const command = SwitchTimelineTheme.getCommand(asContext(mockContext)); await command.run({} as never); @@ -183,9 +171,7 @@ describe('SwitchTimelineTheme', () => { it('should mark default theme with description', async () => { const mockContext = createMockContext(); - const command = SwitchTimelineTheme.getCommand( - mockContext as unknown as import('../../Context.js').Context, - ); + const command = SwitchTimelineTheme.getCommand(asContext(mockContext)); await command.run({} as never); @@ -195,9 +181,7 @@ describe('SwitchTimelineTheme', () => { it('should not mark non-default built-in themes as custom', async () => { const mockContext = createMockContext(); - const command = SwitchTimelineTheme.getCommand( - mockContext as unknown as import('../../Context.js').Context, - ); + const command = SwitchTimelineTheme.getCommand(asContext(mockContext)); await command.run({} as never); @@ -216,9 +200,7 @@ describe('SwitchTimelineTheme', () => { }); const mockContext = createMockContext(); - const command = SwitchTimelineTheme.getCommand( - mockContext as unknown as import('../../Context.js').Context, - ); + const command = SwitchTimelineTheme.getCommand(asContext(mockContext)); await command.run({} as never); @@ -237,9 +219,7 @@ describe('SwitchTimelineTheme', () => { }); const mockContext = createMockContext(); - const command = SwitchTimelineTheme.getCommand( - mockContext as unknown as import('../../Context.js').Context, - ); + const command = SwitchTimelineTheme.getCommand(asContext(mockContext)); await command.run({} as never); @@ -255,9 +235,7 @@ describe('SwitchTimelineTheme', () => { }); const mockContext = createMockContext(); - const command = SwitchTimelineTheme.getCommand( - mockContext as unknown as import('../../Context.js').Context, - ); + const command = SwitchTimelineTheme.getCommand(asContext(mockContext)); await command.run({} as never); @@ -274,9 +252,7 @@ describe('SwitchTimelineTheme', () => { mockGetCurrentView.mockReturnValue({ webview: mockWebview }); const mockContext = createMockContext(); - const command = SwitchTimelineTheme.getCommand( - mockContext as unknown as import('../../Context.js').Context, - ); + const command = SwitchTimelineTheme.getCommand(asContext(mockContext)); await command.run({} as never); @@ -293,9 +269,7 @@ describe('SwitchTimelineTheme', () => { mockGetCurrentView.mockReturnValue(null); const mockContext = createMockContext(); - const command = SwitchTimelineTheme.getCommand( - mockContext as unknown as import('../../Context.js').Context, - ); + const command = SwitchTimelineTheme.getCommand(asContext(mockContext)); await command.run({} as never); @@ -308,9 +282,7 @@ describe('SwitchTimelineTheme', () => { 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, - ); + const command = SwitchTimelineTheme.getCommand(asContext(mockContext)); await command.run({} as never); @@ -324,9 +296,7 @@ describe('SwitchTimelineTheme', () => { it('should hide picker on accept', async () => { const mockContext = createMockContext(); - const command = SwitchTimelineTheme.getCommand( - mockContext as unknown as import('../../Context.js').Context, - ); + const command = SwitchTimelineTheme.getCommand(asContext(mockContext)); await command.run({} as never); @@ -340,9 +310,7 @@ describe('SwitchTimelineTheme', () => { it('reports a failure to save the chosen theme', async () => { mockUpdateConfig.mockRejectedValue(new Error('settings are read-only')); const mockContext = createMockContext(); - const command = SwitchTimelineTheme.getCommand( - mockContext as unknown as import('../../Context.js').Context, - ); + const command = SwitchTimelineTheme.getCommand(asContext(mockContext)); await command.run({} as never); @@ -371,9 +339,7 @@ describe('SwitchTimelineTheme', () => { }); const mockContext = createMockContext(); - const command = SwitchTimelineTheme.getCommand( - mockContext as unknown as import('../../Context.js').Context, - ); + const command = SwitchTimelineTheme.getCommand(asContext(mockContext)); await command.run({} as never); @@ -391,9 +357,7 @@ describe('SwitchTimelineTheme', () => { it('should dispose picker on hide', async () => { const mockContext = createMockContext(); - const command = SwitchTimelineTheme.getCommand( - mockContext as unknown as import('../../Context.js').Context, - ); + const command = SwitchTimelineTheme.getCommand(asContext(mockContext)); await command.run({} as never); onDidHideCallback(); @@ -415,9 +379,7 @@ describe('SwitchTimelineTheme', () => { }); const mockContext = createMockContext(); - const command = SwitchTimelineTheme.getCommand( - mockContext as unknown as import('../../Context.js').Context, - ); + const command = SwitchTimelineTheme.getCommand(asContext(mockContext)); await command.run({} as never); @@ -437,7 +399,7 @@ describe('SwitchTimelineTheme', () => { it('should register command with context', () => { const mockContext = createMockContext(); - SwitchTimelineTheme.apply(mockContext as unknown as import('../../Context.js').Context); + SwitchTimelineTheme.apply(asContext(mockContext)); expect(mockContext.context.subscriptions.length).toBe(1); }); @@ -445,7 +407,7 @@ describe('SwitchTimelineTheme', () => { it('should output registration message', () => { const mockContext = createMockContext(); - SwitchTimelineTheme.apply(mockContext as unknown as import('../../Context.js').Context); + SwitchTimelineTheme.apply(asContext(mockContext)); expect(mockContext.display.output).toHaveBeenCalledWith( "Registered command 'Lana: Timeline Theme'", diff --git a/lana/src/decorations/__tests__/RawLogLineDecoration.test.ts b/lana/src/decorations/__tests__/RawLogLineDecoration.test.ts index 4d82492ae..1748c4cba 100644 --- a/lana/src/decorations/__tests__/RawLogLineDecoration.test.ts +++ b/lana/src/decorations/__tests__/RawLogLineDecoration.test.ts @@ -7,6 +7,7 @@ import { window, type TextDocument, type TextEditor } from 'vscode'; import { createMockApexLog, + asContext, createMockContext, createMockLogEvent, } from '../../__tests__/helpers/test-builders.js'; @@ -19,7 +20,6 @@ import { setOpenTabs, } from '../../__tests__/mocks/vscode.js'; import { LogEventCache } from '../../cache/LogEventCache.js'; -import type { Context } from '../../Context.js'; import { RawLogLineDecoration } from '../RawLogLineDecoration.js'; jest.mock('../../cache/LogEventCache.js', () => ({ @@ -60,13 +60,12 @@ describe('RawLogLineDecoration', () => { let mockContext: ReturnType; beforeEach(() => { - jest.clearAllMocks(); jest.useFakeTimers(); // The class keeps one instance for the life of the extension host. (RawLogLineDecoration as unknown as { instance: unknown }).instance = null; setOpenTabs(new TabInputText(Uri.file(LOG_URI))); mockContext = createMockContext(); - RawLogLineDecoration.apply(mockContext as unknown as Context); + RawLogLineDecoration.apply(asContext(mockContext)); }); afterEach(() => { @@ -79,8 +78,8 @@ describe('RawLogLineDecoration', () => { }); it('registers once, however many times it is applied', () => { - RawLogLineDecoration.apply(mockContext as unknown as Context); - RawLogLineDecoration.apply(mockContext as unknown as Context); + RawLogLineDecoration.apply(asContext(mockContext)); + RawLogLineDecoration.apply(asContext(mockContext)); expect(mockOnSelectionChange).toHaveBeenCalledTimes(1); }); diff --git a/lana/src/display/__tests__/OpenFileInPackage.test.ts b/lana/src/display/__tests__/OpenFileInPackage.test.ts index 6a0aa871e..f0a4c660f 100644 --- a/lana/src/display/__tests__/OpenFileInPackage.test.ts +++ b/lana/src/display/__tests__/OpenFileInPackage.test.ts @@ -2,7 +2,7 @@ * Copyright (c) 2025 Certinia Inc. All rights reserved. */ import { workspace } from 'vscode'; -import type { Context } from '../../Context'; +import { asContext, createMockContext } from '../../__tests__/helpers/test-builders.js'; import { getMethodLine, parseApex } from '../../salesforce/ApexParser/ApexSymbolLocator'; import { OpenFileInPackage } from '../OpenFileInPackage'; @@ -15,20 +15,13 @@ const mockGetMethodLine = getMethodLine as jest.Mock; const mockOpenTextDocument = workspace.openTextDocument as jest.Mock; function createContext() { - const workspaceManager = { - findSymbol: jest.fn(), - }; - const display = { - showErrorMessage: jest.fn(), - showFile: jest.fn(), - }; - const context = { workspaceManager, display } as unknown as Context; - return { context, workspaceManager, display }; + const workspaceManager = { findSymbol: jest.fn() }; + const mock = createMockContext({ workspaceManager }); + return { context: asContext(mock), workspaceManager, display: mock.display }; } describe('OpenFileInPackage.openFileForSymbol', () => { beforeEach(() => { - jest.clearAllMocks(); mockParseApex.mockReturnValue({ name: 'myclass', children: [] }); mockOpenTextDocument.mockResolvedValue({ getText: () => 'public class MyClass {}' }); }); diff --git a/lana/src/display/__tests__/WhatsNewNotification.test.ts b/lana/src/display/__tests__/WhatsNewNotification.test.ts index 9b122a4c4..f58970214 100644 --- a/lana/src/display/__tests__/WhatsNewNotification.test.ts +++ b/lana/src/display/__tests__/WhatsNewNotification.test.ts @@ -5,8 +5,7 @@ import { beforeEach, describe, expect, it } from '@jest/globals'; import { commands, window } from 'vscode'; -import { createMockContext } from '../../__tests__/helpers/test-builders.js'; -import type { Context } from '../../Context.js'; +import { asContext, createMockContext } from '../../__tests__/helpers/test-builders.js'; import { WhatsNewNotification } from '../WhatsNewNotification.js'; const mockShowInformationMessage = window.showInformationMessage as jest.Mock; @@ -22,12 +21,8 @@ function contextForVersion(version: string, viewed: string[] = []) { return mockContext; } -const asContext = (mockContext: ReturnType) => - mockContext as unknown as Context; - describe('WhatsNewNotification', () => { beforeEach(() => { - jest.clearAllMocks(); mockShowInformationMessage.mockResolvedValue(undefined); }); diff --git a/lana/src/folding/__tests__/RawLogFoldingProvider.test.ts b/lana/src/folding/__tests__/RawLogFoldingProvider.test.ts index 8758a7fd8..a9569f06d 100644 --- a/lana/src/folding/__tests__/RawLogFoldingProvider.test.ts +++ b/lana/src/folding/__tests__/RawLogFoldingProvider.test.ts @@ -8,6 +8,7 @@ import { FoldingRangeKind, languages, window, workspace } from 'vscode'; import { createMockDisplay, createMockApexLog, + asContext, createMockContext, createMockLogEvent, } from '../../__tests__/helpers/test-builders.js'; @@ -316,7 +317,7 @@ describe('RawLogFoldingProvider', () => { it('should register folding range provider for apexlog', () => { const mockContext = createMockContext(); - RawLogFoldingProvider.apply(mockContext as unknown as import('../../Context.js').Context); + RawLogFoldingProvider.apply(asContext(mockContext)); expect(languages.registerFoldingRangeProvider).toHaveBeenCalledTimes(1); expect(languages.registerFoldingRangeProvider).toHaveBeenCalledWith( @@ -328,7 +329,7 @@ describe('RawLogFoldingProvider', () => { it('warms on tab changes, not on document open', () => { const mockContext = createMockContext(); - RawLogFoldingProvider.apply(mockContext as unknown as import('../../Context.js').Context); + RawLogFoldingProvider.apply(asContext(mockContext)); // onDidOpenTextDocument fires before the tab model updates, so isOpenAsTextTab // would reject a legitimate open. @@ -339,7 +340,7 @@ describe('RawLogFoldingProvider', () => { it('should add disposables to context subscriptions', () => { const mockContext = createMockContext(); - RawLogFoldingProvider.apply(mockContext as unknown as import('../../Context.js').Context); + RawLogFoldingProvider.apply(asContext(mockContext)); // emitter + folding provider registration + tab listener + active-editor listener expect(mockContext.context.subscriptions.length).toBe(4); @@ -352,7 +353,7 @@ describe('RawLogFoldingProvider', () => { function applyAndCapture() { const mockContext = createMockContext(); - RawLogFoldingProvider.apply(mockContext as unknown as import('../../Context.js').Context); + RawLogFoldingProvider.apply(asContext(mockContext)); const registeredProvider = (languages.registerFoldingRangeProvider as jest.Mock).mock .calls[0]?.[1] as RawLogFoldingProvider; diff --git a/lana/src/language/__tests__/ApexLogLanguageDetector.test.ts b/lana/src/language/__tests__/ApexLogLanguageDetector.test.ts index 4b4f8a506..98da3db04 100644 --- a/lana/src/language/__tests__/ApexLogLanguageDetector.test.ts +++ b/lana/src/language/__tests__/ApexLogLanguageDetector.test.ts @@ -3,7 +3,7 @@ */ import { describe, expect, it } from '@jest/globals'; -import { createMockContext } from '../../__tests__/helpers/test-builders.js'; +import { asContext, createMockContext } from '../../__tests__/helpers/test-builders.js'; import { createMockTextDocument } from '../../__tests__/mocks/vscode.js'; import { TabInputText, @@ -115,9 +115,7 @@ describe('ApexLogLanguageDetector', () => { }); workspace.textDocuments = [doc]; - ApexLogLanguageDetector.apply( - createMockContext() as unknown as import('../../Context.js').Context, - ); + ApexLogLanguageDetector.apply(asContext(createMockContext())); expect(languages.setTextDocumentLanguage).toHaveBeenCalledWith(doc, 'apexlog'); }); @@ -130,9 +128,7 @@ describe('ApexLogLanguageDetector', () => { Object.defineProperty(doc, 'uri', { value: Uri.parse('git:/repository/logs/virtual.json') }); workspace.textDocuments = [doc]; - ApexLogLanguageDetector.apply( - createMockContext() as unknown as import('../../Context.js').Context, - ); + ApexLogLanguageDetector.apply(asContext(createMockContext())); expect(languages.setTextDocumentLanguage).not.toHaveBeenCalled(); }); @@ -142,9 +138,7 @@ describe('ApexLogLanguageDetector', () => { input: new TabInputText(Uri.parse('memfs:/logs/huge.log')), }; - ApexLogLanguageDetector.apply( - createMockContext() as unknown as import('../../Context.js').Context, - ); + ApexLogLanguageDetector.apply(asContext(createMockContext())); expect(commands.executeCommand).toHaveBeenLastCalledWith('setContext', 'lana.isApexLog', true); }); @@ -154,9 +148,7 @@ describe('ApexLogLanguageDetector', () => { input: new TabInputText(Uri.parse('memfs:/logs/huge.log')), }; - ApexLogLanguageDetector.apply( - createMockContext() as unknown as import('../../Context.js').Context, - ); + ApexLogLanguageDetector.apply(asContext(createMockContext())); expect(workspace.fs.readFile).not.toHaveBeenCalled(); }); @@ -166,9 +158,7 @@ describe('ApexLogLanguageDetector', () => { input: new TabInputText(Uri.parse('memfs:/notes.json')), }; - ApexLogLanguageDetector.apply( - createMockContext() as unknown as import('../../Context.js').Context, - ); + ApexLogLanguageDetector.apply(asContext(createMockContext())); expect(commands.executeCommand).toHaveBeenLastCalledWith('setContext', 'lana.isApexLog', false); }); @@ -176,9 +166,7 @@ describe('ApexLogLanguageDetector', () => { it('clears the key when the active tab is not a text tab', () => { window.tabGroups.activeTabGroup.activeTab = { input: {} }; - ApexLogLanguageDetector.apply( - createMockContext() as unknown as import('../../Context.js').Context, - ); + ApexLogLanguageDetector.apply(asContext(createMockContext())); expect(commands.executeCommand).toHaveBeenLastCalledWith('setContext', 'lana.isApexLog', false); }); diff --git a/lana/src/salesforce/__tests__/ApexVisitor.test.ts b/lana/src/salesforce/__tests__/ApexVisitor.test.ts index bd7f67648..1a59e1e15 100644 --- a/lana/src/salesforce/__tests__/ApexVisitor.test.ts +++ b/lana/src/salesforce/__tests__/ApexVisitor.test.ts @@ -360,12 +360,8 @@ describe('ApexVisitor', () => { }); describe('visit', () => { - it('should return empty object when ctx is null', () => { - expect(visitor.visit(asVisitCtx(null))).toEqual({}); - }); - - it('should return empty object when ctx is undefined', () => { - expect(visitor.visit(asVisitCtx(undefined))).toEqual({}); + it.each([null, undefined])('should return empty object when ctx is %p', (ctx) => { + expect(visitor.visit(asVisitCtx(ctx))).toEqual({}); }); it('should call accept on context when ctx exists', () => { diff --git a/lana/src/salesforce/codesymbol/__tests__/SfdxProject.test.ts b/lana/src/salesforce/codesymbol/__tests__/SfdxProject.test.ts index 6143a1f00..88345abbb 100644 --- a/lana/src/salesforce/codesymbol/__tests__/SfdxProject.test.ts +++ b/lana/src/salesforce/codesymbol/__tests__/SfdxProject.test.ts @@ -23,7 +23,6 @@ describe('SfdxProject', () => { const anotherAppUri = fileUri('/workspace/another-app'); beforeEach(() => { - jest.clearAllMocks(); project = createProject([forceAppUri]); }); diff --git a/lana/src/salesforce/codesymbol/__tests__/SfdxProjectReader.test.ts b/lana/src/salesforce/codesymbol/__tests__/SfdxProjectReader.test.ts index 8307b5f12..2a9235252 100644 --- a/lana/src/salesforce/codesymbol/__tests__/SfdxProjectReader.test.ts +++ b/lana/src/salesforce/codesymbol/__tests__/SfdxProjectReader.test.ts @@ -29,7 +29,6 @@ describe('getProjects', () => { } as WorkspaceFolder; beforeEach(() => { - jest.clearAllMocks(); // Mirror the real Uri.joinPath: join segments and normalize '..' (Uri.joinPath as jest.Mock).mockImplementation((base: Uri, ...segments: string[]) => fileUri(joinPath(base.path, ...segments)), diff --git a/lana/src/salesforce/codesymbol/__tests__/SymbolFinder.test.ts b/lana/src/salesforce/codesymbol/__tests__/SymbolFinder.test.ts index abc14537f..3cd9907f6 100644 --- a/lana/src/salesforce/codesymbol/__tests__/SymbolFinder.test.ts +++ b/lana/src/salesforce/codesymbol/__tests__/SymbolFinder.test.ts @@ -31,10 +31,6 @@ function createMockWorkspace(findClassResult: Uri[]): VSWorkspace { } describe('SymbolFinder', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - describe('findSymbol', () => { it('should report not-found when no classes match', async () => { const mockWorkspace = createMockWorkspace([]); diff --git a/lana/src/services/__tests__/salesforceServices.test.ts b/lana/src/services/__tests__/salesforceServices.test.ts index 823f39351..59ba605a3 100644 --- a/lana/src/services/__tests__/salesforceServices.test.ts +++ b/lana/src/services/__tests__/salesforceServices.test.ts @@ -14,7 +14,6 @@ const mockListLogs = jest.fn((limit: number) => ({ limit })); const mockGetLogBody = jest.fn((id: string) => ({ id })); beforeEach(() => { - jest.clearAllMocks(); (getRuntime as jest.Mock).mockReturnValue({ runPromise: mockRunPromise }); (getServicesApi as jest.Mock).mockReturnValue({ services: { ApexLogService: { listLogs: mockListLogs, getLogBody: mockGetLogBody } }, diff --git a/lana/src/services/__tests__/servicesRuntime.test.ts b/lana/src/services/__tests__/servicesRuntime.test.ts index ad7ae2872..34cd862e9 100644 --- a/lana/src/services/__tests__/servicesRuntime.test.ts +++ b/lana/src/services/__tests__/servicesRuntime.test.ts @@ -32,7 +32,6 @@ const validApi = () => ({ describe('servicesRuntime', () => { beforeEach(() => { - jest.clearAllMocks(); mockGetExtension.mockReturnValue(undefined); mockShowErrorMessage.mockResolvedValue(undefined); }); diff --git a/lana/src/symbols/__tests__/RawLogSymbolProvider.test.ts b/lana/src/symbols/__tests__/RawLogSymbolProvider.test.ts index 894133ef0..957374dac 100644 --- a/lana/src/symbols/__tests__/RawLogSymbolProvider.test.ts +++ b/lana/src/symbols/__tests__/RawLogSymbolProvider.test.ts @@ -8,6 +8,7 @@ import { SymbolKind, languages } from 'vscode'; import { createMockDisplay, createMockApexLog, + asContext, createMockContext, createMockLogEvent, } from '../../__tests__/helpers/test-builders.js'; @@ -167,14 +168,14 @@ describe('RawLogSymbolProvider', () => { describe('apply', () => { const applyProvider = () => { const mockContext = createMockContext(); - RawLogSymbolProvider.apply(mockContext as unknown as import('../../Context.js').Context); + RawLogSymbolProvider.apply(asContext(mockContext)); return (languages.registerDocumentSymbolProvider as jest.Mock).mock.calls[0]?.[1] as RawLogSymbolProvider | undefined; }; it('gives the registered provider the context display to report through', async () => { const mockContext = createMockContext(); - RawLogSymbolProvider.apply(mockContext as unknown as import('../../Context.js').Context); + RawLogSymbolProvider.apply(asContext(mockContext)); const registered = (languages.registerDocumentSymbolProvider as jest.Mock).mock .calls[0]?.[1] as RawLogSymbolProvider; mockGetApexLog.mockResolvedValue(null); diff --git a/lana/src/workspace/__tests__/VSWorkspace.test.ts b/lana/src/workspace/__tests__/VSWorkspace.test.ts index cbcb3b45c..a42a9d136 100644 --- a/lana/src/workspace/__tests__/VSWorkspace.test.ts +++ b/lana/src/workspace/__tests__/VSWorkspace.test.ts @@ -20,7 +20,6 @@ describe('VSWorkspace', () => { let vsWorkspace: VSWorkspace; beforeEach(() => { - jest.clearAllMocks(); vsWorkspace = new VSWorkspace(mockWorkspaceFolder); }); diff --git a/lana/src/workspace/__tests__/VSWorkspaceManager.test.ts b/lana/src/workspace/__tests__/VSWorkspaceManager.test.ts index 7927cc857..df98f3a3c 100644 --- a/lana/src/workspace/__tests__/VSWorkspaceManager.test.ts +++ b/lana/src/workspace/__tests__/VSWorkspaceManager.test.ts @@ -27,7 +27,6 @@ function createManager(...folders: object[]): VSWorkspaceManager { describe('VSWorkspaceManager', () => { beforeEach(() => { - jest.clearAllMocks(); (workspace as { workspaceFolders?: unknown[] }).workspaceFolders = undefined; }); diff --git a/log-viewer/src/__tests__/Util.test.ts b/log-viewer/src/__tests__/Util.test.ts deleted file mode 100644 index f504b9397..000000000 --- a/log-viewer/src/__tests__/Util.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright (c) 2020 Certinia Inc. All rights reserved. - */ -import { describe, expect, it } from '@jest/globals'; - -import { formatDuration } from '../core/utility/Util.js'; - -describe('Format duration tests', () => { - it('Shows ms with decimals for very small values (sub-millisecond)', () => { - expect(formatDuration(5)).toBe('0 ms'); // 0.000005 ms rounds to 0 - expect(formatDuration(50)).toBe('0 ms'); // 0.00005 ms rounds to 0 - expect(formatDuration(500)).toBe('0.001 ms'); - expect(formatDuration(1000)).toBe('0.001 ms'); - expect(formatDuration(5000)).toBe('0.005 ms'); - expect(formatDuration(9999)).toBe('0.01 ms'); - expect(formatDuration(10000)).toBe('0.01 ms'); - expect(formatDuration(50000)).toBe('0.05 ms'); - expect(formatDuration(99999)).toBe('0.1 ms'); - }); - - it('handles ms duration', () => { - expect(formatDuration(100_000)).toBe('0.1 ms'); - expect(formatDuration(500_000)).toBe('0.5 ms'); - expect(formatDuration(1_000_000)).toBe('1 ms'); - expect(formatDuration(1_234_567)).toBe('1.23 ms'); - expect(formatDuration(9_999_999)).toBe('10 ms'); - expect(formatDuration(10_000_000)).toBe('10 ms'); - expect(formatDuration(99_999_999)).toBe('100 ms'); - expect(formatDuration(100_000_000)).toBe('100 ms'); - expect(formatDuration(999_000_000)).toBe('999 ms'); - }); - - it('handles zero duration', () => { - expect(formatDuration(0)).toBe('0 ms'); - }); - - it('handles seconds', () => { - expect(formatDuration(5_000_000_000)).toBe('5 s'); - expect(formatDuration(59_500_000_000)).toBe('59.5 s'); - }); - - it('handles minutes and seconds', () => { - expect(formatDuration(60_000_000_000)).toBe('1m'); - expect(formatDuration(125_000_000_000)).toBe('2m 5s'); - expect(formatDuration(125_500_000_000)).toBe('2m 5.5s'); - }); - - it('handles remove trailing 0 for all units types', () => { - expect(formatDuration(5000)).toBe('0.005 ms'); - expect(formatDuration(100_000)).toBe('0.1 ms'); - expect(formatDuration(5_000_000_000)).toBe('5 s'); - expect(formatDuration(60_000_000_000)).toBe('1m'); - }); - - it('handles rounding to appropriate precision', () => { - // sub-milliseconds (up to 3 decimal places) - expect(formatDuration(1234)).toBe('0.001 ms'); - expect(formatDuration(9876)).toBe('0.01 ms'); - - // milliseconds (up to 2 decimal places) - expect(formatDuration(1_234_567)).toBe('1.23 ms'); - expect(formatDuration(9_876_543)).toBe('9.88 ms'); - - // seconds (up to 2 decimal places) - expect(formatDuration(1_234_567_890)).toBe('1.23 s'); - expect(formatDuration(9_876_543_210)).toBe('9.88 s'); - }); - - it('rounds to 1dp for min and s', () => { - // minutes with fractional seconds - expect(formatDuration(125_670_000_000)).toBe('2m 5.7s'); - }); - - describe('compact option', () => { - it('omits spaces for milliseconds', () => { - expect(formatDuration(0, { compact: true })).toBe('0ms'); - expect(formatDuration(50000, { compact: true })).toBe('0.05ms'); - expect(formatDuration(1_000_000, { compact: true })).toBe('1ms'); - expect(formatDuration(1_234_567, { compact: true })).toBe('1.23ms'); - expect(formatDuration(100_000_000, { compact: true })).toBe('100ms'); - }); - - it('omits spaces for seconds', () => { - expect(formatDuration(5_000_000_000, { compact: true })).toBe('5s'); - expect(formatDuration(59_500_000_000, { compact: true })).toBe('59.5s'); - }); - - it('omits spaces for minutes', () => { - expect(formatDuration(60_000_000_000, { compact: true })).toBe('1m'); - expect(formatDuration(125_000_000_000, { compact: true })).toBe('2m5s'); - expect(formatDuration(125_500_000_000, { compact: true })).toBe('2m5.5s'); - }); - }); -}); diff --git a/log-viewer/src/__tests__/helpers/apexLog.ts b/log-viewer/src/__tests__/helpers/apexLog.ts new file mode 100644 index 000000000..0e036a94b --- /dev/null +++ b/log-viewer/src/__tests__/helpers/apexLog.ts @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { LOG_LEVEL, parse, type ApexLog } from 'apex-log-parser'; + +import { logStoreFor, type LogStore } from '../../core/log/LogStore.js'; + +/** + * The header line, which decides what the log records. The level comes from the + * enum the code under test branches on, so the two cannot drift apart. + */ +export const SETTINGS = { + finest: `64.0 APEX_CODE,${LOG_LEVEL.Finest};APEX_PROFILING,NONE;DB,NONE\n`, + fine: `64.0 APEX_CODE,${LOG_LEVEL.Fine};APEX_PROFILING,NONE;DB,NONE\n`, +}; + +/** + * `body` inside the execution and code unit a real log wraps it in. The body is + * what a test is about, so it stays in the test; this is the envelope that has to + * be there for the parser to reach it. + * + * Stamp the body between 200 and 900000. A duration is `exitStamp - timestamp`, + * so a line stamped past the footer gives the code unit a negative one. + */ +function logText(body: string, settings = SETTINGS.finest): string { + return ( + settings + + '09:18:22.6 (100)|EXECUTION_STARTED\n' + + '09:18:22.6 (200)|CODE_UNIT_STARTED|[EXTERNAL]|066d0000002m8ij|apex://pkg.Entry\n' + + body + + '09:18:22.6 (900000)|CODE_UNIT_FINISHED|apex://pkg.Entry\n' + + '09:18:22.6 (901000)|EXECUTION_FINISHED\n' + ); +} + +/** + * {@link logText}, parsed, with the store the components read it through. The + * two are one graph: `store.log` is the same object as `log`. + */ +export function storeOf( + body: string, + settings = SETTINGS.finest, +): { log: ApexLog; store: LogStore } { + const log = parse(logText(body, settings)); + return { log, store: logStoreFor(log) }; +} + +/** The eventIndex of the frame or event whose log text is `text`. */ +export function indexOf(log: ApexLog, text: string): number { + const found = log.eventsById.find((event) => event.text === text); + if (!found) { + throw new Error(`no event with text ${text}`); + } + return found.eventIndex; +} + +/** + * Every frame whose log text is `text`, in log order. Frames only: a METHOD_EXIT + * carries the same text as the entry it closes. + */ +export function indexesOf(log: ApexLog, text: string): number[] { + return log.eventsById + .filter((event) => event.isParent && event.text === text) + .map((event) => event.eventIndex); +} diff --git a/log-viewer/src/__tests__/helpers/events.ts b/log-viewer/src/__tests__/helpers/events.ts new file mode 100644 index 000000000..c4880ec68 --- /dev/null +++ b/log-viewer/src/__tests__/helpers/events.ts @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { LogEvent } from 'apex-log-parser'; + +/** + * The fields the call tree and analysis passes read. Every count is a self/total + * pair, so a test names only the half it asserts on and the rest stay zero. + */ +export interface EventOptions { + text: string; + type?: string; + namespace?: string; + self?: number; + total?: number; + exitStamp?: number | null; + suffix?: string | null; + parent?: LogEvent | null; + dmlSelf?: number; + dmlTotal?: number; + soqlSelf?: number; + soqlTotal?: number; + soslSelf?: number; + soslTotal?: number; + dmlRowSelf?: number; + dmlRowTotal?: number; + soqlRowSelf?: number; + soqlRowTotal?: number; + soslRowSelf?: number; + soslRowTotal?: number; + thrown?: number; + heapSelf?: number; + heapTotal?: number; +} + +// Only so two events are told apart in a debugger. Nothing under test reads it, +// which is why no suite resets it. +let nextTimestamp = 1; + +/** + * A `LogEvent` carrying the fields these passes read, and no others. `Partial` + * rather than a blind cast, so a renamed or reshaped field on the real class + * fails the typecheck here instead of drifting silently. + * + * Do not add `eventIndex`. A built frame has none, so `keyPathIds.ts:61` finds + * `undefined >= 0` false and skips its key cache. Give every frame index 0 — the + * real class default — and they all read back the first frame's key. + */ +export function createEvent(options: EventOptions): LogEvent { + const event: Partial = { + parent: options.parent ?? null, + children: [], + type: (options.type ?? 'METHOD_ENTRY') as LogEvent['type'], + text: options.text, + namespace: options.namespace ?? 'default', + suffix: options.suffix ?? null, + cpuType: '', + timestamp: nextTimestamp++, + exitStamp: options.exitStamp ?? null, + duration: { self: options.self ?? 0, total: options.total ?? 0 }, + dmlRowCount: { self: options.dmlRowSelf ?? 0, total: options.dmlRowTotal ?? 0 }, + soqlRowCount: { self: options.soqlRowSelf ?? 0, total: options.soqlRowTotal ?? 0 }, + soslRowCount: { self: options.soslRowSelf ?? 0, total: options.soslRowTotal ?? 0 }, + dmlCount: { self: options.dmlSelf ?? 0, total: options.dmlTotal ?? 0 }, + soqlCount: { self: options.soqlSelf ?? 0, total: options.soqlTotal ?? 0 }, + soslCount: { self: options.soslSelf ?? 0, total: options.soslTotal ?? 0 }, + thrownCount: { self: options.thrown ?? 0, total: options.thrown ?? 0 }, + heapAllocated: { self: options.heapSelf ?? 0, total: options.heapTotal ?? 0 }, + heapGross: { self: 0, total: 0 }, + heapPeak: 0, + }; + + const built = event as LogEvent; + options.parent?.children.push(built); + return built; +} diff --git a/log-viewer/src/__tests__/helpers/flameChart.ts b/log-viewer/src/__tests__/helpers/flameChart.ts new file mode 100644 index 000000000..c6fd2942e --- /dev/null +++ b/log-viewer/src/__tests__/helpers/flameChart.ts @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { jest } from '@jest/globals'; + +import type { FlameChart } from '../../features/timeline/optimised/FlameChart.js'; +import type { RenderDirtyState } from '../../features/timeline/types/flamechart.types.js'; + +/** + * The chart's private fields, writable. Reaching past the public API is what + * these suites are for: they drive `render` and `resize` without a canvas. + */ +export function internalsOf(chart: FlameChart): Record { + return chart as unknown as Record; +} + +/** + * The private collaborators no suite varies, stubbed, and the same record back to + * write the rest onto. `app`, `viewport` and the hit-test collaborators stay with + * the suite, which needs a different handle on each to observe its own case. + * + * Naming the shared fields once means a rename inside FlameChart lands here + * rather than in each suite. + * + * Every dirty flag starts false, unlike the real `init()`, so a case proves the + * render it asked for rather than the one init already asked for. `dirty` names + * the flags a case does depend on. + */ +export function stubChartInternals( + chart: FlameChart, + dirty: Partial = {}, +): Record { + const internals = internalsOf(chart); + internals['container'] = document.createElement('div'); + internals['index'] = { maxDepth: 1 }; + internals['worldContainer'] = { position: { set: jest.fn() } }; + internals['batchRenderer'] = { render: jest.fn(), clear: jest.fn() }; + internals['rectangleManager'] = { + getCulledRectangles: () => ({ visibleRects: new Map(), buckets: new Map() }), + }; + internals['state'] = { + needsRender: false, + batchColorsCache: new Map(), + renderDirty: { + background: false, + culling: false, + eventRendering: false, + highlights: false, + overlays: false, + minimap: false, + metricStrip: false, + ...dirty, + }, + }; + return internals; +} diff --git a/log-viewer/src/__tests__/helpers/mount.ts b/log-viewer/src/__tests__/helpers/mount.ts new file mode 100644 index 000000000..8bf87d45b --- /dev/null +++ b/log-viewer/src/__tests__/helpers/mount.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { LitElement } from 'lit'; + +/** Props to assign, or a callback for a suite that needs more than assignment. */ +type Configure = Partial | ((element: T) => void); + +/** + * Create a custom element, configure it, put it in the document and wait for its first + * render. A suite needing more settling than one render awaits it itself, so the extra + * wait stays where a reader can see what it is for. + * + * The tag is a string, not a `HTMLElementTagNameMap` key: 44 of the 65 components declare + * no map entry, so the element type is named at the call site. + */ +export async function mountElement( + tag: string, + configure?: Configure, +): Promise { + const element = document.createElement(tag) as unknown as T; + if (!('updateComplete' in element)) { + // Without this, a tag that is unregistered or registered to a non-Lit element awaits its + // undefined `updateComplete` happily and the suite fails later on a null shadowRoot. The + // usual cause is a missing side-effect `import '../Thing.js'`: a type-only import of the + // class is elided, so the module never runs and never registers. + throw new Error(`<${tag}> is not a rendered Lit element — is its module imported?`); + } + if (typeof configure === 'function') { + configure(element); + } else if (configure) { + Object.assign(element, configure); + } + document.body.appendChild(element); + await element.updateComplete; + return element; +} diff --git a/log-viewer/src/__tests__/helpers/viewport.ts b/log-viewer/src/__tests__/helpers/viewport.ts new file mode 100644 index 000000000..d0a3711ed --- /dev/null +++ b/log-viewer/src/__tests__/helpers/viewport.ts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { ViewportState } from '../../features/timeline/types/flamechart.types.js'; + +/** + * A viewport a test can vary one field of. The canvas a renderer measures against + * is stated here once, so a case that does depend on its size says so by passing it. + */ +export function makeViewport(over: Partial = {}): ViewportState { + return { + zoom: 1, + offsetX: 0, + offsetY: 0, + displayWidth: 1000, + displayHeight: 600, + ...over, + }; +} diff --git a/log-viewer/src/__tests__/mocks/vscodeElementStub.ts b/log-viewer/src/__tests__/mocks/vscodeElementStub.ts new file mode 100644 index 000000000..8f503c8c7 --- /dev/null +++ b/log-viewer/src/__tests__/mocks/vscodeElementStub.ts @@ -0,0 +1,10 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * Stands in for a `#vscode-elements/*` import under jest. The real modules register custom + * elements jsdom cannot construct, and an importer only needs the tag to exist in its + * template — an unregistered tag renders as an unknown element and loses no coverage. + */ +export {}; diff --git a/log-viewer/src/__tests__/setup.ts b/log-viewer/src/__tests__/setup.ts index c25f5ca09..538ec287e 100644 --- a/log-viewer/src/__tests__/setup.ts +++ b/log-viewer/src/__tests__/setup.ts @@ -16,3 +16,18 @@ class NoopResizeObserver implements ResizeObserver { if (!('ResizeObserver' in globalThis)) { (globalThis as unknown as Record).ResizeObserver = NoopResizeObserver; } + +/** + * A mounted component stays connected until something removes it, and its + * `connectedCallback` subscriptions stay live with it. One test's leftovers then + * hear the next test's events. `replaceChildren` over `innerHTML = ''` so + * `disconnectedCallback` runs and the subscriptions actually release. + * + * The suites run under `node` unless a file asks for jsdom, so there is not + * always a document. + */ +if (typeof document !== 'undefined') { + afterEach(() => { + document.body.replaceChildren(); + }); +} diff --git a/log-viewer/src/components/VsSelect.ts b/log-viewer/src/components/VsSelect.ts index 92ccbc564..5b7ec0864 100644 --- a/log-viewer/src/components/VsSelect.ts +++ b/log-viewer/src/components/VsSelect.ts @@ -232,6 +232,10 @@ export const selectSizingStyles = css` `; /** vscode-single-select where the control fits the selected value and the popup its widest option. */ +// jest.config.js stubs every `#vscode-elements/*` module and exempts this one, because a stub +// cannot be extended. A new component that extends a vendor element needs the same exemption. +// The exemption keeps the real element, which is form-associated: a suite that mounts +// `` has to shim `ElementInternals.setFormValue`, absent in jsdom. @customElement('vs-select') export class VsSelect extends VscodeSingleSelect { static styles = [...VscodeSingleSelect.styles, ...globalStyles, selectSizingStyles]; diff --git a/log-viewer/src/components/__tests__/AnchoredPopover.test.ts b/log-viewer/src/components/__tests__/AnchoredPopover.test.ts index 1dbea84d6..2f423fc41 100644 --- a/log-viewer/src/components/__tests__/AnchoredPopover.test.ts +++ b/log-viewer/src/components/__tests__/AnchoredPopover.test.ts @@ -5,22 +5,19 @@ */ import { describe, expect, it } from '@jest/globals'; -// jsdom can't run the real elements (they read document.baseURI / setFormValue). -jest.mock('#vscode-elements/vscode-icon.js', () => ({})); - +import { mountElement } from '../../__tests__/helpers/mount.js'; import type { AnchoredPopover } from '../AnchoredPopover.js'; import '../AnchoredPopover.js'; async function mount(panelContent: string, showHeading = false): Promise { - const el = document.createElement('anchored-popover') as AnchoredPopover; - el.heading = 'Log problems'; - el.emptyMessage = 'No problems found in this log'; - if (showHeading) { - el.setAttribute('show-heading', ''); - } - el.innerHTML = `face${panelContent}`; - document.body.appendChild(el); - await el.updateComplete; + const el = await mountElement('anchored-popover', (e) => { + e.heading = 'Log problems'; + e.emptyMessage = 'No problems found in this log'; + if (showHeading) { + e.setAttribute('show-heading', ''); + } + e.innerHTML = `face${panelContent}`; + }); // The panel slot is only readable after the first render, which schedules a second. await el.updateComplete; return el; diff --git a/log-viewer/src/components/__tests__/CallStackDetail.test.ts b/log-viewer/src/components/__tests__/CallStackDetail.test.ts index 802bce40b..ecde5bb4c 100644 --- a/log-viewer/src/components/__tests__/CallStackDetail.test.ts +++ b/log-viewer/src/components/__tests__/CallStackDetail.test.ts @@ -41,6 +41,7 @@ jest.mock('../callStackData.js', () => ({ buildCallStackData: () => ({ rows: [], rootTotal: 0 }), })); +import { mountElement } from '../../__tests__/helpers/mount.js'; import type { CallStackDetail } from '../CallStackDetail.js'; import '../CallStackDetail.js'; import { @@ -50,13 +51,8 @@ import { type InspectorRevealEvent, } from '../inspectorReveal.js'; -async function mount(eventIndex: number): Promise { - const el = document.createElement('call-stack-detail') as CallStackDetail; - el.eventIndex = eventIndex; - document.body.appendChild(el); - await el.updateComplete; - return el; -} +const mount = (eventIndex: number): Promise => + mountElement('call-stack-detail', { eventIndex }); describe('CallStackDetail', () => { it('renders the table host and a context menu for the row actions', async () => { diff --git a/log-viewer/src/components/__tests__/CallTreeDetail.test.ts b/log-viewer/src/components/__tests__/CallTreeDetail.test.ts index 868b878db..e1e394f99 100644 --- a/log-viewer/src/components/__tests__/CallTreeDetail.test.ts +++ b/log-viewer/src/components/__tests__/CallTreeDetail.test.ts @@ -18,11 +18,10 @@ jest.mock('tabulator-tables', () => ({ Module: class {}, Renderer: class {}, })); -// vscode-button needs ElementInternals.setFormValue (absent in jsdom). -jest.mock('#vscode-elements/vscode-button.js', () => ({})); import type { CellComponent } from 'tabulator-tables'; +import { mountElement } from '../../__tests__/helpers/mount.js'; import type { LogStore } from '../../core/log/LogStore.js'; import type { CallTreeDetail } from '../CallTreeDetail.js'; import '../CallTreeDetail.js'; @@ -64,15 +63,12 @@ function nameTooltip( const thisLog = {} as LogStore; const nextLog = {} as LogStore; -async function mount(props: Partial = {}): Promise { - const el = document.createElement('call-tree-detail') as CallTreeDetail; - el.eventIndex = -1; // no selection in the test — no table is built - el.logStore = thisLog; - Object.assign(el, props); - document.body.appendChild(el); - await el.updateComplete; - return el; -} +const mount = (props: Partial = {}): Promise => + mountElement('call-tree-detail', { + eventIndex: -1, // no selection in the test — no table is built + logStore: thisLog, + ...props, + }); async function pick(el: CallTreeDetail, value: string): Promise { switchEl(el).dispatchEvent( diff --git a/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts b/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts index 173cfac11..472435928 100644 --- a/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts +++ b/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts @@ -28,8 +28,6 @@ jest.mock('tabulator-tables', () => { } return { Tabulator, Module: class {}, Renderer: class {} }; }); -// vscode-button needs ElementInternals.setFormValue (absent in jsdom). -jest.mock('#vscode-elements/vscode-button.js', () => ({})); // The walk is what this suite is about, so it's stubbed; each test says whether // it yields a tree or nothing, and when. @@ -44,6 +42,8 @@ jest.mock('../scopedCallTree.js', () => ({ import { Tabulator, type RowComponent } from 'tabulator-tables'; +import { mountElement } from '../../__tests__/helpers/mount.js'; +import { waitForNextFrame } from '../../core/utility/FrameBudget.js'; import type { CallTreeDetail } from '../CallTreeDetail.js'; import '../CallTreeDetail.js'; import { buildScopedCallTree, type ScopedCallTree, type ScopedRow } from '../scopedCallTree.js'; @@ -113,25 +113,15 @@ async function settle(el: CallTreeDetail): Promise { /** Lets the rAF the build waits behind fire, then settles the render. */ async function frame(el: CallTreeDetail): Promise { - await new Promise((resolve) => requestAnimationFrame(resolve)); + await waitForNextFrame(); await settle(el); } -async function mount( - eventIndex: number, - sourceView?: 'callers' | 'callees', -): Promise { - const el = document.createElement('call-tree-detail') as CallTreeDetail; - el.eventIndex = eventIndex; - el.sourceView = sourceView; - document.body.appendChild(el); - await el.updateComplete; - return el; -} +const mount = (eventIndex: number, sourceView?: 'callers' | 'callees'): Promise => + mountElement('call-tree-detail', { eventIndex, sourceView }); describe('CallTreeDetail scoped build', () => { beforeEach(() => { - document.body.replaceChildren(); build.mockReset(); build.mockResolvedValue(null); tables.instances.length = 0; diff --git a/log-viewer/src/components/__tests__/CodeBlock.test.ts b/log-viewer/src/components/__tests__/CodeBlock.test.ts index 2650779c0..20d3e068c 100644 --- a/log-viewer/src/components/__tests__/CodeBlock.test.ts +++ b/log-viewer/src/components/__tests__/CodeBlock.test.ts @@ -5,18 +5,12 @@ */ import { beforeAll, describe, expect, it } from '@jest/globals'; -jest.mock('#vscode-elements/vscode-icon.js', () => ({})); - +import { mountElement } from '../../__tests__/helpers/mount.js'; import type { CodeBlock } from '../CodeBlock.js'; import '../CodeBlock.js'; -async function mount(configure: (el: CodeBlock) => void): Promise { - const el = document.createElement('code-block') as CodeBlock; - configure(el); - document.body.appendChild(el); - await el.updateComplete; - return el; -} +const mount = (configure: (el: CodeBlock) => void): Promise => + mountElement('code-block', configure); describe('CodeBlock', () => { beforeAll(() => { diff --git a/log-viewer/src/components/__tests__/ColorSwatch.test.ts b/log-viewer/src/components/__tests__/ColorSwatch.test.ts index 86983422e..5dc906b02 100644 --- a/log-viewer/src/components/__tests__/ColorSwatch.test.ts +++ b/log-viewer/src/components/__tests__/ColorSwatch.test.ts @@ -5,16 +5,12 @@ */ import { describe, expect, it } from '@jest/globals'; +import { mountElement } from '../../__tests__/helpers/mount.js'; import type { ColorSwatch } from '../ColorSwatch.js'; import '../ColorSwatch.js'; -async function mount(props: Partial> = {}) { - const element = document.createElement('color-swatch'); - Object.assign(element, props); - document.body.appendChild(element); - await element.updateComplete; - return element; -} +const mount = (props: Partial> = {}) => + mountElement('color-swatch', props); describe('ColorSwatch', () => { it('paints itself in the colour it is given', async () => { diff --git a/log-viewer/src/components/__tests__/DetailDock.test.ts b/log-viewer/src/components/__tests__/DetailDock.test.ts index c9539d306..becd34d21 100644 --- a/log-viewer/src/components/__tests__/DetailDock.test.ts +++ b/log-viewer/src/components/__tests__/DetailDock.test.ts @@ -6,9 +6,7 @@ import { beforeAll, describe, expect, it } from '@jest/globals'; import { html } from 'lit'; -jest.mock('#vscode-elements/vscode-icon.js', () => ({})); -jest.mock('#vscode-elements/vscode-badge.js', () => ({})); - +import { mountElement } from '../../__tests__/helpers/mount.js'; import type { DetailDock } from '../DetailDock.js'; import '../DetailDock.js'; import type { PaneSection } from '../PaneView.js'; @@ -18,13 +16,8 @@ const sections: PaneSection[] = [ { id: 'b', title: 'B', content: html`
b
` }, ]; -async function mount(configure: (el: DetailDock) => void): Promise { - const el = document.createElement('detail-dock') as DetailDock; - configure(el); - document.body.appendChild(el); - await el.updateComplete; - return el; -} +const mount = (configure: (el: DetailDock) => void): Promise => + mountElement('detail-dock', configure); describe('DetailDock', () => { beforeAll(() => { diff --git a/log-viewer/src/components/__tests__/DockLayout.test.ts b/log-viewer/src/components/__tests__/DockLayout.test.ts index edcab3e47..22ed1e084 100644 --- a/log-viewer/src/components/__tests__/DockLayout.test.ts +++ b/log-viewer/src/components/__tests__/DockLayout.test.ts @@ -5,9 +5,7 @@ */ import { describe, expect, it, beforeAll } from '@jest/globals'; -// pulls in vscode-elements icons, which need APIs jsdom lacks. -jest.mock('../DetailDock.js', () => ({})); - +import { mountElement } from '../../__tests__/helpers/mount.js'; import type { DockLayout } from '../DockLayout.js'; import '../DockLayout.js'; @@ -25,15 +23,8 @@ beforeAll(() => { Object.defineProperty(HTMLElement.prototype, 'clientHeight', { value: 800, configurable: true }); }); -async function mount(dock: 'left' | 'right' | 'bottom', size = 500): Promise { - const el = document.createElement('dock-layout') as DockLayout; - el.dock = dock; - el.size = size; - el.visible = true; - document.body.appendChild(el); - await el.updateComplete; - return el; -} +const mount = (dock: 'left' | 'right' | 'bottom', size = 500): Promise => + mountElement('dock-layout', { dock, size, visible: true }); function gutter(el: DockLayout): HTMLElement { const found = el.shadowRoot?.querySelector('.gutter'); diff --git a/log-viewer/src/components/__tests__/EventVitals.test.ts b/log-viewer/src/components/__tests__/EventVitals.test.ts index be3de3b91..e7ee5751f 100644 --- a/log-viewer/src/components/__tests__/EventVitals.test.ts +++ b/log-viewer/src/components/__tests__/EventVitals.test.ts @@ -6,6 +6,7 @@ import { beforeAll, describe, expect, it } from '@jest/globals'; import { parse } from 'apex-log-parser'; +import { mountElement } from '../../__tests__/helpers/mount.js'; import { logStoreFor, type LogStore } from '../../core/log/LogStore.js'; // Avoid the heavy CodeBlock import chain (vscode-elements, soql formatter); the @@ -42,13 +43,8 @@ function valueFor(el: EventVitals, label: string): string | undefined { } /** No provider in the test, so the consumed store is assigned straight on. */ -async function mount(store: LogStore, props: Partial): Promise { - const el = document.createElement('event-vitals') as EventVitals; - Object.assign(el, { logStore: store }, props); - document.body.appendChild(el); - await el.updateComplete; - return el; -} +const mount = (store: LogStore, props: Partial): Promise => + mountElement('event-vitals', { logStore: store, ...props }); describe('EventVitals', () => { let store: LogStore; diff --git a/log-viewer/src/components/__tests__/GovernorTrends.test.ts b/log-viewer/src/components/__tests__/GovernorTrends.test.ts index 01b342d52..94822730b 100644 --- a/log-viewer/src/components/__tests__/GovernorTrends.test.ts +++ b/log-viewer/src/components/__tests__/GovernorTrends.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it } from '@jest/globals'; import type { LitElement } from 'lit'; +import { mountElement } from '../../__tests__/helpers/mount.js'; import { eventBus } from '../../core/events/EventBus.js'; import type { LogStore } from '../../core/log/LogStore.js'; import type { TrendSeries } from '../governorTrendData.js'; @@ -21,6 +22,7 @@ jest.mock('../../features/timeline/optimised/apex-limit-series.js', () => ({ apexLimitTimeSeries: () => ({ events: [] }), })); +import type { GovernorTrends } from '../GovernorTrends.js'; import '../GovernorTrends.js'; const LOG_NS = 1_000; @@ -40,14 +42,9 @@ const trend = (label = 'SOQL queries'): TrendSeries => ({ const aLog = () => ({ log: { duration: { total: LOG_NS } } }) as unknown as LogStore; -async function mount(): Promise { - const element = document.createElement('governor-trends'); +const mount = (): Promise => // No provider in the test, so the consumed store is assigned straight on. - (element as unknown as { logStore: LogStore }).logStore = aLog(); - document.body.append(element); - await element.updateComplete; - return element; -} + mountElement('governor-trends', { logStore: aLog() }); /** The chart, given a width so a pointer x maps to a time. */ function chartOf(element: LitElement, at = 0): HTMLButtonElement { @@ -73,7 +70,6 @@ let seeks: { timestamp?: number; mode?: string }[]; let unsubscribe: () => void; beforeEach(() => { - document.body.replaceChildren(); series = [trend()]; seeks = []; unsubscribe?.(); @@ -304,18 +300,13 @@ describe('governor-trends', () => { return [...classes].find((name) => name.startsWith('trend--')) ?? null; }; - it('reads safe below the warn threshold', async () => { - expect(await tierOf(79)).toBe('trend--safe'); - }); - - it('warns from the threshold', async () => { + // Where the bands fall is proved once, in + // features/database/components/__tests__/GovernorSummary.test.ts. This proves the + // trend applies them, and that finalRatio reaches governorTier as a percent. + it('puts the tier the policy gives on the figure', async () => { expect(await tierOf(80)).toBe('trend--warn'); }); - it('reads danger at the limit', async () => { - expect(await tierOf(100)).toBe('trend--danger'); - }); - // No limit, so no tier to report against. it('takes none where the log reported no limit', async () => { expect(await tierOf(90, 0)).toBeNull(); diff --git a/log-viewer/src/components/__tests__/HeaderMenu.test.ts b/log-viewer/src/components/__tests__/HeaderMenu.test.ts index c569a1344..37f514b03 100644 --- a/log-viewer/src/components/__tests__/HeaderMenu.test.ts +++ b/log-viewer/src/components/__tests__/HeaderMenu.test.ts @@ -5,25 +5,16 @@ */ import { describe, expect, it } from '@jest/globals'; -// jsdom can't run the real elements (they read document.baseURI / setFormValue). -jest.mock('#vscode-elements/vscode-icon.js', () => ({})); - +import { mountElement } from '../../__tests__/helpers/mount.js'; import type { HeaderMenu } from '../HeaderMenu.js'; import '../HeaderMenu.js'; -async function mount( +const mount = ( marker: boolean, collapsed = '', collapsedCount = collapsed ? 1 : 0, -): Promise { - const el = document.createElement('header-menu') as HeaderMenu; - el.marker = marker; - el.collapsedCount = collapsedCount; - el.innerHTML = collapsed; - document.body.appendChild(el); - await el.updateComplete; - return el; -} +): Promise => + mountElement('header-menu', { marker, collapsedCount, innerHTML: collapsed }); function rowLabels(el: HeaderMenu): string[] { return Array.from(el.shadowRoot?.querySelectorAll('.filter-popover-row') ?? []).map( diff --git a/log-viewer/src/components/__tests__/HotPath.test.ts b/log-viewer/src/components/__tests__/HotPath.test.ts index 0f9f3b5d4..e36a628e9 100644 --- a/log-viewer/src/components/__tests__/HotPath.test.ts +++ b/log-viewer/src/components/__tests__/HotPath.test.ts @@ -5,11 +5,10 @@ */ import { beforeEach, describe, expect, it } from '@jest/globals'; +import { mountElement } from '../../__tests__/helpers/mount.js'; import type { LogStore } from '../../core/log/LogStore.js'; import type { ExecutionHighlights } from '../../features/call-tree/utils/ExecutionHighlights.js'; -jest.mock('#vscode-elements/vscode-icon.js', () => ({})); - let highlights: ExecutionHighlights | null = null; jest.mock('../../features/call-tree/utils/ExecutionHighlights.js', () => ({ getExecutionHighlights: () => highlights, @@ -37,13 +36,10 @@ const pathOf = (frameCount: number): ExecutionHighlights => ({ truncation: null, }); -const hotPath = async () => { - const element = document.createElement('hot-path'); - element.logStore = { log: {} } as unknown as LogStore; - document.body.append(element); - await element.updateComplete; - return element; -}; +const hotPath = () => + mountElement('hot-path', { + logStore: { log: {} } as unknown as LogStore, + }); const rowNames = (element: Element) => [...element.shadowRoot!.querySelectorAll('.reveal-row__name')].map((name) => name.textContent); @@ -61,7 +57,6 @@ const rowCaptions = (element: Element) => describe('hot-path', () => { beforeEach(() => { - document.body.replaceChildren(); highlights = null; }); diff --git a/log-viewer/src/components/__tests__/HotSpots.test.ts b/log-viewer/src/components/__tests__/HotSpots.test.ts index 874502943..1dcd0eadc 100644 --- a/log-viewer/src/components/__tests__/HotSpots.test.ts +++ b/log-viewer/src/components/__tests__/HotSpots.test.ts @@ -5,6 +5,7 @@ */ import { beforeEach, describe, expect, it } from '@jest/globals'; +import { mountElement } from '../../__tests__/helpers/mount.js'; import type { LogStore } from '../../core/log/LogStore.js'; import type { ExecutionHighlights } from '../../features/call-tree/utils/ExecutionHighlights.js'; @@ -34,17 +35,13 @@ const spotsOf = (): ExecutionHighlights => ({ truncation: null, }); -const hotSpots = async () => { - const element = document.createElement('hot-spots'); - element.logStore = { log: {} } as unknown as LogStore; - document.body.append(element); - await element.updateComplete; - return element; -}; +const hotSpots = () => + mountElement('hot-spots', { + logStore: { log: {} } as unknown as LogStore, + }); describe('hot-spots', () => { beforeEach(() => { - document.body.replaceChildren(); highlights = null; }); diff --git a/log-viewer/src/components/__tests__/LogInspector.test.ts b/log-viewer/src/components/__tests__/LogInspector.test.ts index cd156ad0f..2de60b82a 100644 --- a/log-viewer/src/components/__tests__/LogInspector.test.ts +++ b/log-viewer/src/components/__tests__/LogInspector.test.ts @@ -6,9 +6,6 @@ import { beforeEach, describe, expect, it } from '@jest/globals'; import { html } from 'lit'; -jest.mock('#vscode-elements/vscode-icon.js', () => ({})); -jest.mock('#vscode-elements/vscode-badge.js', () => ({})); -jest.mock('#vscode-elements/vscode-button.js', () => ({})); // The swc transform can't parse `.scss`/`.css`; stub the stylesheet assets. jest.mock('../../tabulator/style/DataGrid.scss', () => ({ default: '' })); jest.mock('../../tabulator/format/Progress.css', () => ({})); @@ -79,6 +76,8 @@ jest.mock('../detailSections.js', () => ({ }, })); +import { mountElement } from '../../__tests__/helpers/mount.js'; +import { waitForNextFrame } from '../../core/utility/FrameBudget.js'; import { eventBus, type DetailSource } from '../../core/events/EventBus.js'; import type { LogStore } from '../../core/log/LogStore.js'; import type { LogInspector } from '../LogInspector.js'; @@ -102,7 +101,7 @@ async function settle(el: LogInspector): Promise { /** `settle`, plus the rAF wait that lets the debounced rebuild fire first. */ async function flush(el: LogInspector): Promise { - await new Promise((resolve) => requestAnimationFrame(resolve)); + await waitForNextFrame(); await settle(el); } @@ -112,9 +111,7 @@ function inspectorSettings(overrides: Record = {}): Record { - const el = document.createElement('log-inspector') as LogInspector; - el.activeTab = activeTab; - document.body.appendChild(el); + const el = await mountElement('log-inspector', { activeTab }); await flush(el); return el; } @@ -229,7 +226,6 @@ describe('LogInspector', () => { deferSections = false; pendingSections.length = 0; builtHiding.length = 0; - document.body.replaceChildren(); }); it('applies the persisted collapse to the list it was made in', async () => { @@ -397,7 +393,7 @@ describe('LogInspector', () => { // awaited; the toggle must not be undone when that build lands. deferSections = true; select('timeline', 2); - await new Promise((resolve) => requestAnimationFrame(resolve)); + await waitForNextFrame(); openSectionMenu(el); pickMenuItem(el, 'section:callstack'); await settle(el); @@ -860,9 +856,9 @@ describe('LogInspector', () => { deferSections = true; select('timeline', 1); - await new Promise((resolve) => requestAnimationFrame(resolve)); // debounce fires -> _rebuild() epoch 1 starts, awaiting buildDetailSections + await waitForNextFrame(); // debounce fires -> _rebuild() epoch 1 starts, awaiting buildDetailSections select('timeline', 2); - await new Promise((resolve) => requestAnimationFrame(resolve)); // debounce fires -> _rebuild() epoch 2 starts, awaiting buildDetailSections + await waitForNextFrame(); // debounce fires -> _rebuild() epoch 2 starts, awaiting buildDetailSections expect(pendingSections).toHaveLength(2); // The newer selection's build resolves first (it's the one the user is diff --git a/log-viewer/src/components/__tests__/LogOverview.test.ts b/log-viewer/src/components/__tests__/LogOverview.test.ts index fada16750..018033280 100644 --- a/log-viewer/src/components/__tests__/LogOverview.test.ts +++ b/log-viewer/src/components/__tests__/LogOverview.test.ts @@ -6,6 +6,7 @@ import type { GovernorLimits } from 'apex-log-parser'; import { beforeEach, describe, expect, it } from '@jest/globals'; +import { mountElement } from '../../__tests__/helpers/mount.js'; import type { LogStore } from '../../core/log/LogStore.js'; import type { HeatStripTimeSeries } from '../../features/timeline/types/flamechart.types.js'; import { emptyLimits, seriesEvent, timeSeries } from './limitsTestUtils.js'; @@ -20,12 +21,7 @@ jest.mock('../../features/timeline/optimised/apex-limit-series.js', () => ({ import type { LogOverview } from '../LogOverview.js'; import '../LogOverview.js'; -const overview = async () => { - const element = document.createElement('log-overview'); - document.body.append(element); - await element.updateComplete; - return element; -}; +const overview = () => mountElement('log-overview'); /** No provider in the test, so the consumed store is assigned straight on. */ const loadLog = async (element: LogOverview, governorLimits: GovernorLimits) => { @@ -35,7 +31,6 @@ const loadLog = async (element: LogOverview, governorLimits: GovernorLimits) => describe('log-overview', () => { beforeEach(() => { - document.body.replaceChildren(); mockSeries = timeSeries(); }); diff --git a/log-viewer/src/components/__tests__/LogProblemsChip.test.ts b/log-viewer/src/components/__tests__/LogProblemsChip.test.ts index 0ac2c28fd..df7dd2e03 100644 --- a/log-viewer/src/components/__tests__/LogProblemsChip.test.ts +++ b/log-viewer/src/components/__tests__/LogProblemsChip.test.ts @@ -5,10 +5,7 @@ */ import { describe, expect, it } from '@jest/globals'; -// jsdom can't run the real elements (they read document.baseURI / setFormValue). -jest.mock('#vscode-elements/vscode-icon.js', () => ({})); -jest.mock('#vscode-elements/vscode-button.js', () => ({})); - +import { mountElement } from '../../__tests__/helpers/mount.js'; import type { IssueSeverity, LogIssue } from '../../features/notifications/types.js'; import type { LogProblemsChip } from '../LogProblemsChip.js'; @@ -26,13 +23,8 @@ function issue(severity: IssueSeverity): LogIssue { }; } -async function mount(issues: readonly LogIssue[] | null): Promise { - const el = document.createElement('log-problems') as LogProblemsChip; - el.issues = issues; - document.body.appendChild(el); - await el.updateComplete; - return el; -} +const mount = (issues: readonly LogIssue[] | null): Promise => + mountElement('log-problems', { issues }); function chip(el: LogProblemsChip): HTMLElement | null { return el.shadowRoot?.querySelector('.header-control') ?? null; diff --git a/log-viewer/src/components/__tests__/NamespaceTimeBar.test.ts b/log-viewer/src/components/__tests__/NamespaceTimeBar.test.ts index ca4cde2af..da5a47979 100644 --- a/log-viewer/src/components/__tests__/NamespaceTimeBar.test.ts +++ b/log-viewer/src/components/__tests__/NamespaceTimeBar.test.ts @@ -8,6 +8,7 @@ import type { ApexLog } from 'apex-log-parser'; let apexLog: ApexLog | null = null; +import { mountElement } from '../../__tests__/helpers/mount.js'; import type { LogStore } from '../../core/log/LogStore.js'; import type { NamespaceTimeBar } from '../NamespaceTimeBar.js'; import { DEFAULT_MAX_SEGMENTS } from '../StackedTimeBar.js'; @@ -20,7 +21,6 @@ const logOf = (children: FakeEvent[], namespaces: string[]) => { }; async function mount(props: Partial> = {}) { - const element = document.createElement('namespace-time-bar'); // No provider in the test, so the consumed store is assigned straight on. const store = apexLog && @@ -28,8 +28,10 @@ async function mount(props: Partial eventByIndex(index), } as unknown as LogStore); - Object.assign(element, { logStore: store }, props); - document.body.append(element); + const element = await mountElement('namespace-time-bar', { + logStore: store, + ...props, + }); // The first render only starts the walk; the result lands a task later. for (let settle = 0; settle < 5; settle++) { await element.updateComplete; @@ -46,7 +48,6 @@ const segments = (element: NamespaceTimeBar) => bar(element)?.segments ?? []; describe('namespace-time-bar', () => { beforeEach(() => { - document.body.replaceChildren(); resetEvents(); apexLog = null; }); diff --git a/log-viewer/src/components/__tests__/NavBar.test.ts b/log-viewer/src/components/__tests__/NavBar.test.ts index 3e2756a1d..dd5863204 100644 --- a/log-viewer/src/components/__tests__/NavBar.test.ts +++ b/log-viewer/src/components/__tests__/NavBar.test.ts @@ -5,11 +5,7 @@ */ import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'; -// jsdom can't run the real elements (they read document.baseURI / setFormValue). -jest.mock('#vscode-elements/vscode-icon.js', () => ({})); -jest.mock('#vscode-elements/vscode-toolbar-button.js', () => ({})); -jest.mock('#vscode-elements/vscode-button.js', () => ({})); - +import { mountElement } from '../../__tests__/helpers/mount.js'; import type { LogIdentityData } from '../../features/app/logIdentity.js'; import type { IssueSeverity, LogIssue } from '../../features/notifications/types.js'; @@ -116,18 +112,15 @@ function issue(severity: IssueSeverity): LogIssue { }; } -async function mount( +const mount = ( problems: readonly LogIssue[] = [], notifications: readonly LogIssue[] = [], -): Promise { - const el = document.createElement('nav-bar') as NavBar; - el.logName = 'test.log'; - el.logProblems = problems; - el.notifications = notifications; - document.body.appendChild(el); - await el.updateComplete; - return el; -} +): Promise => + mountElement('nav-bar', { + logName: 'test.log', + logProblems: problems, + notifications, + }); async function resize(el: NavBar, width: number): Promise { notify?.(width); diff --git a/log-viewer/src/components/__tests__/PaneView.test.ts b/log-viewer/src/components/__tests__/PaneView.test.ts index c12c7e108..2f210cc4f 100644 --- a/log-viewer/src/components/__tests__/PaneView.test.ts +++ b/log-viewer/src/components/__tests__/PaneView.test.ts @@ -6,9 +6,7 @@ import { beforeAll, describe, expect, it } from '@jest/globals'; import { html } from 'lit'; -jest.mock('#vscode-elements/vscode-icon.js', () => ({})); -jest.mock('#vscode-elements/vscode-badge.js', () => ({})); - +import { mountElement } from '../../__tests__/helpers/mount.js'; import type { PaneOrientation, PaneSection, PaneView } from '../PaneView.js'; import '../PaneView.js'; @@ -22,16 +20,15 @@ const sections: PaneSection[] = [ * Collapse is controlled: the consumer owns the record and feeds it back. Mount * with that loop wired, the way the inspector does. */ -async function mountSections( +const mountSections = ( paneSections: PaneSection[], props: Partial = {}, -): Promise { - const el = document.createElement('pane-view') as PaneView; - Object.assign(el, { orientation: 'vertical', sections: paneSections }, props); - document.body.appendChild(el); - await el.updateComplete; - return el; -} +): Promise => + mountElement('pane-view', { + orientation: 'vertical', + sections: paneSections, + ...props, + }); /** With the collapse loop wired, the way the inspector owns the record. */ async function mount(orientation: PaneOrientation): Promise { diff --git a/log-viewer/src/components/__tests__/SectionSkeleton.test.ts b/log-viewer/src/components/__tests__/SectionSkeleton.test.ts index 42c18f647..94374a53c 100644 --- a/log-viewer/src/components/__tests__/SectionSkeleton.test.ts +++ b/log-viewer/src/components/__tests__/SectionSkeleton.test.ts @@ -5,22 +5,17 @@ */ import { describe, expect, it } from '@jest/globals'; +import { mountElement } from '../../__tests__/helpers/mount.js'; import type { LogStatus } from '../../core/log/logStatus.js'; import { NO_GOVERNOR_USAGE_TEXT, NO_LOG_TEXT } from '../governorCopy.js'; import type { SectionSkeleton, SkeletonShape } from '../SectionSkeleton.js'; import '../SectionSkeleton.js'; -async function mount(props: { +const mount = (props: { logStatus?: LogStatus; shape?: SkeletonShape; fallback?: string; -}): Promise { - const el = document.createElement('section-skeleton') as SectionSkeleton; - Object.assign(el, props); - document.body.appendChild(el); - await el.updateComplete; - return el; -} +}): Promise => mountElement('section-skeleton', props); /** How many bars each row holds, top to bottom. */ function rowWidths(el: SectionSkeleton): string[][] { diff --git a/log-viewer/src/components/__tests__/StackedTimeBar.test.ts b/log-viewer/src/components/__tests__/StackedTimeBar.test.ts index 45bf28a31..364b30aa7 100644 --- a/log-viewer/src/components/__tests__/StackedTimeBar.test.ts +++ b/log-viewer/src/components/__tests__/StackedTimeBar.test.ts @@ -3,25 +3,19 @@ * * @jest-environment jsdom */ -import { beforeEach, describe, expect, it } from '@jest/globals'; +import { describe, expect, it } from '@jest/globals'; +import { mountElement } from '../../__tests__/helpers/mount.js'; import '../StackedTimeBar.js'; -import type { StackedSegment } from '../StackedTimeBar.js'; +import type { StackedSegment, StackedTimeBar } from '../StackedTimeBar.js'; const SEGMENTS: StackedSegment[] = [ { label: 'SOQL', value: 200_000_000, color: 'red' }, { label: 'DML', value: 100_000_000, color: 'blue' }, ]; -async function mount(segments: StackedSegment[], total = 0, legend = false) { - const element = document.createElement('stacked-time-bar'); - element.segments = segments; - element.total = total; - element.legend = legend; - document.body.append(element); - await element.updateComplete; - return element; -} +const mount = (segments: StackedSegment[], total = 0, legend = false) => + mountElement('stacked-time-bar', { segments, total, legend }); const widths = (element: Element) => [...(element.shadowRoot?.querySelectorAll('rect') ?? [])].map((rect) => @@ -29,10 +23,6 @@ const widths = (element: Element) => ); describe('stacked-time-bar', () => { - beforeEach(() => { - document.body.replaceChildren(); - }); - it('renders nothing without a length to show', async () => { expect((await mount([])).shadowRoot?.querySelector('svg')).toBeNull(); }); diff --git a/log-viewer/src/components/__tests__/VariablesDetail.test.ts b/log-viewer/src/components/__tests__/VariablesDetail.test.ts index ac3413599..9ebb92d0f 100644 --- a/log-viewer/src/components/__tests__/VariablesDetail.test.ts +++ b/log-viewer/src/components/__tests__/VariablesDetail.test.ts @@ -4,16 +4,15 @@ * @jest-environment jsdom */ import { describe, expect, it } from '@jest/globals'; -import { parse } from 'apex-log-parser'; +import { SETTINGS, indexOf, indexesOf, storeOf } from '../../__tests__/helpers/apexLog.js'; +import { mountElement } from '../../__tests__/helpers/mount.js'; import { MAX_MARKED_PER_VALUE } from '../../core/log/aggregateVariables.js'; -import { logStoreFor, type LogStore } from '../../core/log/LogStore.js'; +import type { LogStore } from '../../core/log/LogStore.js'; -// Avoid the heavy CodeBlock import chain (vscode-elements, soql formatter). The +// Avoid the heavy CodeBlock import chain (the soql formatter). The // raw value it renders is covered by variableValue's own tests. jest.mock('../CodeBlock.js', () => ({})); -// The chevron is a vscode-icon, and its connectedCallback throws under jsdom. -jest.mock('#vscode-elements/vscode-icon.js', () => ({})); const frameReads: { store: unknown; index: unknown }[] = []; jest.mock('../../core/log/frameVariables.js', () => { @@ -32,21 +31,7 @@ jest.mock('../../core/log/frameVariables.js', () => { import { STATICS_NOTE, type VariablesDetail } from '../VariablesDetail.js'; import '../VariablesDetail.js'; -const FINEST = '64.0 APEX_CODE,FINEST;APEX_PROFILING,NONE;DB,NONE\n'; -const FINE = '64.0 APEX_CODE,FINE;APEX_PROFILING,NONE;DB,NONE\n'; - -function logOf(body: string, settings = FINEST): LogStore { - return logStoreFor( - parse( - settings + - '09:18:22.6 (100)|EXECUTION_STARTED\n' + - '09:18:22.6 (200)|CODE_UNIT_STARTED|[EXTERNAL]|066d0000002m8ij|apex://pkg.Entry\n' + - body + - '09:18:22.6 (900000)|CODE_UNIT_FINISHED|apex://pkg.Entry\n' + - '09:18:22.6 (901000)|EXECUTION_FINISHED\n', - ), - ); -} +const logOf = (body: string, settings?: string): LogStore => storeOf(body, settings).store; const FRAME = '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + @@ -56,21 +41,12 @@ const FRAME = '09:18:22.6 (1300)|VARIABLE_ASSIGNMENT|[4]|ns.Cache.hits|{"a":1,"b":2}\n' + '09:18:22.6 (1700)|METHOD_EXIT|[1]|ns.Outer.run()\n'; -/** The eventIndex of the frame whose log text is `text`. */ -function indexOf(store: LogStore, text: string): number { - const found = store.log.eventsById.find((event) => event.text === text); - if (!found) { - throw new Error(`no event with text ${text}`); - } - return found.eventIndex; -} - /** No provider in the test, so the consumed store is assigned straight on. */ async function mount(store: LogStore, props: Partial): Promise { - const el = document.createElement('variables-detail') as VariablesDetail; - Object.assign(el, { logStore: store }, props); - document.body.appendChild(el); - await el.updateComplete; + const el = await mountElement('variables-detail', { + logStore: store, + ...props, + }); // The statics index is built on the first ask, so the first paint is a note. await el.updateComplete; await new Promise((resolve) => setTimeout(resolve, 0)); @@ -117,14 +93,14 @@ describe('VariablesDetail swapping logs', () => { // The wrong read is discarded before it paints, so only the read itself shows it. it('does not read the new log through the index of the last one', async () => { const first = logOf(FRAME); - const el = await mount(first, { eventIndex: indexOf(first, 'ns.Outer.run()') }); + const el = await mount(first, { eventIndex: indexOf(first.log, 'ns.Outer.run()') }); expect(rowNames(el)).toContain('total'); const firstIndex = frameReads.find((read) => read.store === first)?.index; expect(firstIndex).toBeDefined(); const second = logOf(FRAME); el.logStore = second; - el.eventIndex = indexOf(second, 'ns.Outer.run()'); + el.eventIndex = indexOf(second.log, 'ns.Outer.run()'); await el.updateComplete; expect(frameReads.some((read) => read.store === second && read.index === firstIndex)).toBe( @@ -145,7 +121,7 @@ describe('VariablesDetail read failure', () => { }); const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); expect(notes(el)).toContain('Could not read the log for variables.'); errorSpy.mockRestore(); @@ -158,8 +134,8 @@ describe('VariablesDetail skips the frame read for an aggregate', () => { it('never asks for the frame when more than one instance is selected', async () => { const store = logOf(FRAME); const el = await mount(store, { - eventIndex: indexOf(store, 'ns.Outer.run()'), - frames: [indexOf(store, 'ns.Outer.run()'), indexOf(store, 'ns.Outer.run()')], + eventIndex: indexOf(store.log, 'ns.Outer.run()'), + frames: [indexOf(store.log, 'ns.Outer.run()'), indexOf(store.log, 'ns.Outer.run()')], }); expect((el as unknown as { _frame: unknown })._frame).toBeNull(); @@ -170,9 +146,9 @@ describe('VariablesDetail empty states', () => { // Telling a FINEST user to set FINEST is the worst answer available, so each // case has to read differently. it('names the log level that would fill it', async () => { - const store = logOf(FRAME, FINE); + const store = logOf(FRAME, SETTINGS.fine); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); expect(notes(el)).toEqual(['Variables available with the Apex Code log level at FINEST.']); }); @@ -180,7 +156,7 @@ describe('VariablesDetail empty states', () => { it('says a FINEST log recorded no write at all', async () => { const store = logOf('09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n'); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); expect(notes(el)).toEqual(['This log records no variable assignments.']); }); @@ -192,7 +168,7 @@ describe('VariablesDetail empty states', () => { '09:18:22.6 (1900)|METHOD_EXIT|[9]|ns.Quiet.run()\n', ); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Quiet.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Quiet.run()') }); // The statics are still visible from it, so this frame reports them. expect(groupNames(el)).toContain('Static'); @@ -237,7 +213,7 @@ describe('VariablesDetail groups', () => { it('shows Local, this and Static, in that order', async () => { const store = logOf(FRAME); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); expect(groupNames(el)).toEqual(['Local', 'this', 'Static']); }); @@ -245,7 +221,7 @@ describe('VariablesDetail groups', () => { it('opens Local and leaves the rest closed', async () => { const store = logOf(FRAME); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); const groups = treeRows(el).filter((row) => row.querySelector('.group-name')); expect(groups.map((row) => row.getAttribute('aria-expanded'))).toEqual([ @@ -261,7 +237,7 @@ describe('VariablesDetail groups', () => { it('gives every row that opens a chevron, and every other row its gap', async () => { const store = logOf(FRAME); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); for (const row of treeRows(el)) { const opens = row.getAttribute('aria-expanded') !== null; @@ -273,7 +249,7 @@ describe('VariablesDetail groups', () => { it('shows the declared type the log recorded', async () => { const store = logOf(FRAME); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); expect(rowText(el)).toContain('Integer'); }); @@ -285,11 +261,11 @@ describe('VariablesDetail groups', () => { '09:18:22.6 (1850)|VARIABLE_ASSIGNMENT|[10]|other|7\n' + '09:18:22.6 (1900)|METHOD_EXIT|[9]|ns.Second.run()\n', ); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); treeRows(el)[0]?.click(); await el.updateComplete; - el.eventIndex = indexOf(store, 'ns.Second.run()'); + el.eventIndex = indexOf(store.log, 'ns.Second.run()'); await el.updateComplete; expect(treeRows(el)[0]?.getAttribute('aria-expanded')).toBe('false'); @@ -299,7 +275,7 @@ describe('VariablesDetail groups', () => { it('offers no expander on a value a row can hold', async () => { const store = logOf(FRAME); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); // `total` is 42, so its row opens on nothing. const total = rowNamed(el, 'total'); @@ -314,7 +290,7 @@ describe('VariablesDetail groups', () => { '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|held|{"a":1,"b":2}\n' + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', ); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); const held = () => treeRows(el).find((row) => row.dataset.id === 'local/held'); const before = held()?.querySelector('.value')?.textContent?.trim(); @@ -336,7 +312,7 @@ describe('VariablesDetail groups', () => { '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', ); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); expect(rowText(el)).toContain('not assigned'); }); @@ -345,7 +321,7 @@ describe('VariablesDetail groups', () => { it('joins a name to its value with a colon', async () => { const store = logOf(FRAME); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); expect(rowNamed(el, 'total')?.querySelector('.name')?.textContent).toBe('total:'); }); @@ -358,7 +334,7 @@ describe('VariablesDetail groups', () => { '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', ); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); expect(rowNamed(el, 'never')?.querySelector('.name')?.textContent).toBe('never'); }); @@ -372,7 +348,7 @@ describe('VariablesDetail groups', () => { '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', ); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); const alias = rowNamed(el, 'alias'); expect(alias?.querySelector('.missing')?.textContent).toContain('no value recorded'); @@ -392,7 +368,7 @@ describe('VariablesDetail groups', () => { '09:18:22.6 (1600)|METHOD_EXIT|[4]|ns.Outer.after()\n', ); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); const alias = rowNamed(el, 'alias'); expect(alias?.querySelector('.missing')?.textContent).toContain('recorded later'); @@ -415,7 +391,7 @@ describe('VariablesDetail groups', () => { '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', ); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); const writer = rowNamed(el, 'writer'); // The row has no width for the namespace, so the hover carries it whole. @@ -438,7 +414,7 @@ describe('VariablesDetail groups', () => { '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', ); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); expect(rowNamed(el, 'writer')?.querySelector('.cls')).toBeNull(); }); @@ -454,7 +430,7 @@ describe('VariablesDetail groups', () => { '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Writer.run()\n', ); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Writer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Writer.run()') }); expect(rowNames(el)).not.toContain('this'); expect(groupNames(el)).toContain('this'); @@ -471,7 +447,7 @@ describe('VariablesDetail groups', () => { '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', ); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); expect(rowNamed(el, 'payload')?.querySelector('.chip')?.textContent).toBe('json'); }); @@ -484,7 +460,7 @@ describe('VariablesDetail groups', () => { '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', ); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); const alias = rowNamed(el, 'alias'); expect(alias?.querySelector('.value')?.textContent).toContain('Id'); @@ -507,7 +483,7 @@ describe('VariablesDetail keyboard', () => { '09:18:22.6 (1000)|METHOD_ENTRY|[2]|01p|ns.Outer.run()\n' + '09:18:22.6 (1300)|METHOD_EXIT|[2]|ns.Outer.run()\n', ); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); // Local starts open with no locals, so it opens onto a note. expect(tabStop(el)).toBe('local'); @@ -522,7 +498,7 @@ describe('VariablesDetail keyboard', () => { it('gives the tree one tab stop', async () => { const store = logOf(FRAME); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); expect(el.shadowRoot?.querySelectorAll('[tabindex="0"]')).toHaveLength(1); expect(tabStop(el)).toBe('local'); @@ -530,7 +506,7 @@ describe('VariablesDetail keyboard', () => { it('walks down and up', async () => { const store = logOf(FRAME); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); await press(el, 'ArrowDown'); const second = tabStop(el); @@ -542,7 +518,7 @@ describe('VariablesDetail keyboard', () => { it('opens with the right arrow and closes with the left', async () => { const store = logOf(FRAME); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); // Walk to `this`, which starts closed. while (tabStop(el) !== 'this') { @@ -559,7 +535,7 @@ describe('VariablesDetail keyboard', () => { it('steps out to the row that holds it', async () => { const store = logOf(FRAME); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); // Local is open, so the row below it is one of its own. await press(el, 'ArrowDown'); @@ -570,7 +546,7 @@ describe('VariablesDetail keyboard', () => { it('reaches the first and last row', async () => { const store = logOf(FRAME); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); await press(el, 'End'); const last = tabStop(el); @@ -582,7 +558,7 @@ describe('VariablesDetail keyboard', () => { it('opens every group at one depth with a star', async () => { const store = logOf(FRAME); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); await press(el, '*'); @@ -596,7 +572,7 @@ describe('VariablesDetail keyboard', () => { it('toggles with Enter', async () => { const store = logOf(FRAME); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); await press(el, 'Enter'); @@ -607,7 +583,7 @@ describe('VariablesDetail keyboard', () => { describe('VariablesDetail keyboard, key repeat', () => { it('holds a row where a held Enter left it, rather than flapping', async () => { const store = logOf(FRAME); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); await press(el, 'Enter'); await press(el, 'Enter', { repeat: true }); @@ -620,7 +596,7 @@ describe('VariablesDetail keyboard, key repeat', () => { it('leaves a group closed that a held star would re-open', async () => { const store = logOf(FRAME); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); const local = () => treeRows(el).find((row) => row.dataset.id === 'local'); // Local starts open, and holds the tab stop, so Enter closes it. @@ -632,7 +608,7 @@ describe('VariablesDetail keyboard, key repeat', () => { it('keeps the key consumed on a suppressed repeat', async () => { const store = logOf(FRAME); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); const event = await press(el, 'Enter', { repeat: true }); @@ -641,7 +617,7 @@ describe('VariablesDetail keyboard, key repeat', () => { it('walks on every repeat, so holding an arrow scrubs', async () => { const store = logOf(FRAME); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); await press(el, 'ArrowDown'); const second = tabStop(el); @@ -660,7 +636,7 @@ describe('VariablesDetail reads the scope once per selection', () => { it('keeps the same reading when a row opens', async () => { const store = logOf(FRAME); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); const before = held(el); treeRows(el)[0]?.click(); @@ -679,7 +655,7 @@ describe('VariablesDetail reads the scope once per selection', () => { // pay it again: a held arrow key fires ~20 times a second. it('keeps the same rows when the tab stop moves', async () => { const store = logOf(FRAME); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); const before = rows(el); tree(el).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); @@ -691,7 +667,7 @@ describe('VariablesDetail reads the scope once per selection', () => { it('builds the rows again when a row opens', async () => { const store = logOf(FRAME); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); const before = rows(el); treeRows(el)[0]?.click(); @@ -707,10 +683,10 @@ describe('VariablesDetail reads the scope once per selection', () => { '09:18:22.6 (1850)|VARIABLE_ASSIGNMENT|[10]|other|7\n' + '09:18:22.6 (1900)|METHOD_EXIT|[9]|ns.Second.run()\n', ); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); const before = held(el); - el.eventIndex = indexOf(store, 'ns.Second.run()'); + el.eventIndex = indexOf(store.log, 'ns.Second.run()'); await el.updateComplete; expect(held(el)).not.toBe(before); @@ -725,7 +701,7 @@ describe('VariablesDetail properties', () => { '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|outer|{"inner":{"a":1,"b":2},"n":3}\n' + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', ); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); const at = (id: string) => treeRows(el).find((r) => r.dataset.id === id); at('local/outer')?.click(); @@ -746,7 +722,7 @@ describe('VariablesDetail properties', () => { '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|outer|{"n":3}\n' + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', ); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); treeRows(el) .find((r) => r.dataset.id === 'local/outer') @@ -778,7 +754,7 @@ describe('VariablesDetail object fields', () => { it('counts the fields beside a value the log wrote as {}', async () => { const store = logOf(BUILT); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); const holder = rowNamed(el, 'holder'); expect(holder?.querySelector('.count')?.textContent?.trim()).toBe('1'); @@ -789,7 +765,7 @@ describe('VariablesDetail object fields', () => { it('previews the recorded fields on the closed row', async () => { const store = logOf(BUILT); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); const holder = rowNamed(el, 'holder'); expect(holder?.querySelector('.value')?.textContent).toContain('sObj: "Account"'); @@ -806,7 +782,7 @@ describe('VariablesDetail object fields', () => { '09:18:22.6 (1040)|METHOD_EXIT|[1]|ns.Outer.run()\n', ); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); const big = rowNamed(el, 'big'); expect(big?.querySelector('.missing')).toBeNull(); @@ -824,7 +800,7 @@ describe('VariablesDetail object fields', () => { '09:18:22.6 (1030)|VARIABLE_ASSIGNMENT|[3]|holder|{}|0xddd\n' + '09:18:22.6 (1040)|METHOD_EXIT|[1]|ns.Loop.run()\n', ); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Loop.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Loop.run()') }); // The field names the object it belongs to, so it may be read but not opened. treeRows(el) @@ -841,7 +817,7 @@ describe('VariablesDetail object fields', () => { it('offers nothing to open where the log recorded no fields', async () => { const store = logOf(BUILT); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); const plain = rowNamed(el, 'plain'); expect(plain?.querySelector('.count')).toBeNull(); @@ -866,17 +842,10 @@ describe('VariablesDetail comparing a merged row', () => { const CALLS = call(1000, 'true') + call(2000, 'false') + call(3000, 'false'); - /** Every frame whose text is `text`: a METHOD_EXIT carries the entry's text. */ - function framesOf(store: LogStore, text: string): number[] { - return store.log.eventsById - .filter((event) => event.isParent && event.text === text) - .map((event) => event.eventIndex); - } - /** The comparison on screen, once its walk has answered. */ async function compared(body = CALLS): Promise { const store = logOf(body); - const frames = framesOf(store, 'ns.Svc.run()'); + const frames = indexesOf(store.log, 'ns.Svc.run()'); const el = await mount(store, { eventIndex: frames[0]!, frames }); // The comparison is a walk of its own, after the index it reads through. await new Promise((resolve) => setTimeout(resolve, 0)); @@ -945,7 +914,7 @@ describe('VariablesDetail comparing a merged row', () => { const el = await compared(); const values = await valuesOf(el, 'retry'); const seen = locates(el); - const calls = framesOf(el.logStore!, 'ns.Svc.run()'); + const calls = indexesOf(el.logStore!.log, 'ns.Svc.run()'); values[0]?.dispatchEvent(new MouseEvent('pointerenter', { bubbles: true })); values[0]?.dispatchEvent(new MouseEvent('pointerleave', { bubbles: true })); @@ -961,7 +930,7 @@ describe('VariablesDetail comparing a merged row', () => { const el = await compared(); const values = await valuesOf(el, 'retry'); const seen = locates(el); - const calls = framesOf(el.logStore!, 'ns.Svc.run()'); + const calls = indexesOf(el.logStore!.log, 'ns.Svc.run()'); values[1]?.dispatchEvent(new MouseEvent('click', { bubbles: true })); @@ -975,7 +944,7 @@ describe('VariablesDetail comparing a merged row', () => { const el = await compared(); await valuesOf(el, 'retry'); const seen = locates(el); - const calls = framesOf(el.logStore!, 'ns.Svc.run()'); + const calls = indexesOf(el.logStore!.log, 'ns.Svc.run()'); // Opening `retry` left the tab stop on it, so one step down lands on its // first value. @@ -1055,7 +1024,7 @@ describe('VariablesDetail comparing a merged row', () => { it('does not say the statics are not compared for a single frame', async () => { const store = logOf(FRAME); - const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const el = await mount(store, { eventIndex: indexOf(store.log, 'ns.Outer.run()') }); expect(notes(el)).not.toContain(STATICS_NOTE); }); @@ -1075,15 +1044,13 @@ describe('VariablesDetail comparing a merged row', () => { '09:18:22.6 (1070)|METHOD_EXIT|[3]|ns.Svc.query()\n' + '09:18:22.6 (1080)|METHOD_EXIT|[1]|ns.Outer.run()\n', ); - const calls = store.log.eventsById - .filter((event) => event.isParent && event.text === 'ns.Svc.query()') - .map((event) => event.eventIndex); + const calls = indexesOf(store.log, 'ns.Svc.query()'); // What a bottom-up caller row hands over: the calls it counts, and the one // frame that made them. const el = await mount(store, { eventIndex: calls[0]!, - frames: [indexOf(store, 'ns.Outer.run()')], + frames: [indexOf(store.log, 'ns.Outer.run()')], }); expect(rowNames(el)).toContain('outerLocal'); diff --git a/log-viewer/src/components/__tests__/ViewModeSwitch.test.ts b/log-viewer/src/components/__tests__/ViewModeSwitch.test.ts index d3bad7c70..b2d83b353 100644 --- a/log-viewer/src/components/__tests__/ViewModeSwitch.test.ts +++ b/log-viewer/src/components/__tests__/ViewModeSwitch.test.ts @@ -5,10 +5,7 @@ */ import { describe, expect, it } from '@jest/globals'; -// vscode-button needs ElementInternals.setFormValue (absent in jsdom); skip its -// registration so `` stays a plain element we can assert on. -jest.mock('#vscode-elements/vscode-button.js', () => ({})); - +import { mountElement } from '../../__tests__/helpers/mount.js'; import type { ViewModeSwitch } from '../ViewModeSwitch.js'; import '../ViewModeSwitch.js'; @@ -22,14 +19,8 @@ function buttons(el: ViewModeSwitch): HTMLElement[] { return Array.from(el.shadowRoot?.querySelectorAll('vscode-button') ?? []) as HTMLElement[]; } -async function mount(value = 'a'): Promise { - const el = document.createElement('view-mode-switch') as ViewModeSwitch; - el.options = OPTIONS; - el.value = value; - document.body.appendChild(el); - await el.updateComplete; - return el; -} +const mount = (value = 'a'): Promise => + mountElement('view-mode-switch', { options: OPTIONS, value }); describe('ViewModeSwitch', () => { it('renders one button per option and marks the active one non-secondary', async () => { diff --git a/log-viewer/src/components/__tests__/skeletonStatus.test.ts b/log-viewer/src/components/__tests__/skeletonStatus.test.ts index 28ef3416d..1d6674a3e 100644 --- a/log-viewer/src/components/__tests__/skeletonStatus.test.ts +++ b/log-viewer/src/components/__tests__/skeletonStatus.test.ts @@ -5,10 +5,6 @@ */ import { describe, expect, it } from '@jest/globals'; -// jsdom can't run the real elements (they read document.baseURI / setFormValue). -jest.mock('#vscode-elements/vscode-icon.js', () => ({})); -jest.mock('#vscode-elements/vscode-button.js', () => ({})); - import { ContextProvider } from '@lit/context'; import { logStatusContext, type LogStatus } from '../../core/log/logStatus.js'; diff --git a/log-viewer/src/core/log/__tests__/aggregateVariables.test.ts b/log-viewer/src/core/log/__tests__/aggregateVariables.test.ts index 3bf0b5f79..34bb1f812 100644 --- a/log-viewer/src/core/log/__tests__/aggregateVariables.test.ts +++ b/log-viewer/src/core/log/__tests__/aggregateVariables.test.ts @@ -2,7 +2,8 @@ * Copyright (c) 2026 Certinia Inc. All rights reserved. */ import { describe, expect, it, jest } from '@jest/globals'; -import { type ApexLog, parse } from 'apex-log-parser'; + +import { indexesOf, storeOf } from '../../../__tests__/helpers/apexLog.js'; import { aggregateVariablesFor, @@ -10,34 +11,11 @@ import { MAX_VALUES_PER_NAME, } from '../aggregateVariables.js'; import { variableIndexFor } from '../frameVariables.js'; -import { logStoreFor, type LogStore } from '../LogStore.js'; - -const SETTINGS = '64.0 APEX_CODE,FINEST;APEX_PROFILING,NONE;DB,NONE\n'; /** Resolves at once, so a test measures the walk rather than the frames it * would leave to the next paint. */ const yieldSlice = (): Promise => Promise.resolve(); -function storeOf(body: string): { log: ApexLog; store: LogStore } { - const log = parse( - SETTINGS + - '09:18:22.6 (100)|EXECUTION_STARTED\n' + - '09:18:22.6 (200)|CODE_UNIT_STARTED|[EXTERNAL]|066d0000002m8ij|apex://pkg.Entry\n' + - body + - '09:18:22.6 (900000)|CODE_UNIT_FINISHED|apex://pkg.Entry\n' + - '09:18:22.6 (901000)|EXECUTION_FINISHED\n', - ); - return { log, store: logStoreFor(log) }; -} - -/** Every frame whose log text is `text`, in log order. Frames only: a - * METHOD_EXIT carries the same text as the entry it closes. */ -function indexesOf(log: ApexLog, text: string): number[] { - return log.eventsById - .filter((event) => event.isParent && event.text === text) - .map((event) => event.eventIndex); -} - /** One call of `ns.Svc.run()` writing `retry` and `accountId`. */ function call(at: number, retry: string, accountId: string): string { const t = (offset: number): string => `09:18:22.6 (${at + offset})`; diff --git a/log-viewer/src/core/log/__tests__/frameVariables.test.ts b/log-viewer/src/core/log/__tests__/frameVariables.test.ts index 5046f7d59..0927eb2e4 100644 --- a/log-viewer/src/core/log/__tests__/frameVariables.test.ts +++ b/log-viewer/src/core/log/__tests__/frameVariables.test.ts @@ -2,7 +2,8 @@ * Copyright (c) 2026 Certinia Inc. All rights reserved. */ import { describe, expect, it } from '@jest/globals'; -import { type ApexLog, parse } from 'apex-log-parser'; + +import { SETTINGS, indexOf, storeOf } from '../../../__tests__/helpers/apexLog.js'; import { apexCodeLevel, @@ -10,35 +11,6 @@ import { recordsVariables, variableIndexFor, } from '../frameVariables.js'; -import { logStoreFor, type LogStore } from '../LogStore.js'; - -const SETTINGS = '64.0 APEX_CODE,FINEST;APEX_PROFILING,NONE;DB,NONE\n'; - -/** Wraps `body` in the header and footer the parser needs to build a tree. */ -function logOf(body: string, settings = SETTINGS): string { - return ( - settings + - '09:18:22.6 (100)|EXECUTION_STARTED\n' + - '09:18:22.6 (200)|CODE_UNIT_STARTED|[EXTERNAL]|066d0000002m8ij|apex://pkg.Entry\n' + - body + - '09:18:22.6 (900000)|CODE_UNIT_FINISHED|apex://pkg.Entry\n' + - '09:18:22.6 (901000)|EXECUTION_FINISHED\n' - ); -} - -function storeOf(body: string, settings = SETTINGS): { log: ApexLog; store: LogStore } { - const log = parse(logOf(body, settings)); - return { log, store: logStoreFor(log) }; -} - -/** The eventIndex of the frame or event whose log text is `text`. */ -function indexOf(log: ApexLog, text: string): number { - const found = log.eventsById.find((event) => event.text === text); - if (!found) { - throw new Error(`no event with text ${text}`); - } - return found.eventIndex; -} const OUTER = '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + @@ -52,12 +24,14 @@ const OUTER = describe('apexCodeLevel', () => { it('reads the level the log was captured at', () => { - expect(apexCodeLevel(storeOf('').log)).toBe('FINEST'); - expect(recordsVariables(storeOf('').log)).toBe(true); + const { log } = storeOf('', SETTINGS.finest); + + expect(apexCodeLevel(log)).toBe('FINEST'); + expect(recordsVariables(log)).toBe(true); }); it('tells a level that records no variables from one that does', () => { - const { log } = storeOf('', '64.0 APEX_CODE,FINE;APEX_PROFILING,NONE;DB,NONE\n'); + const { log } = storeOf('', SETTINGS.fine); expect(apexCodeLevel(log)).toBe('FINE'); expect(recordsVariables(log)).toBe(false); @@ -107,14 +81,13 @@ describe('frameVariablesFor', () => { // The inner call sits between the two writes to `total`. const atInner = frameVariablesFor(store, indexOf(log, 'ns.Inner.step()'), null); const outerIndex = indexOf(log, 'ns.Outer.run()'); - const inner = log.eventsById.find((event) => event.text === 'ns.Inner.step()')!; const fromParent = frameVariablesFor(store, outerIndex, null); // Asked of the inner frame, the answer is the inner frame's own scope. expect(atInner?.frameLabel).toBe('ns.Inner.step()'); // Asked of the outer frame, both of its writes are in. expect(fromParent?.locals[0]?.value).toBe('2'); - expect(inner.eventIndex).toBeGreaterThan(outerIndex); + expect(indexOf(log, 'ns.Inner.step()')).toBeGreaterThan(outerIndex); }); it('splits instance fields out of the locals', () => { diff --git a/log-viewer/src/__tests__/EventSearch.test.ts b/log-viewer/src/core/utility/__tests__/EventSearch.test.ts similarity index 94% rename from log-viewer/src/__tests__/EventSearch.test.ts rename to log-viewer/src/core/utility/__tests__/EventSearch.test.ts index e371a4b4c..623f558c9 100644 --- a/log-viewer/src/__tests__/EventSearch.test.ts +++ b/log-viewer/src/core/utility/__tests__/EventSearch.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from '@jest/globals'; import { parse } from 'apex-log-parser'; -import { findEventByEventIndex } from '../core/utility/EventSearch.js'; +import { findEventByEventIndex } from '../EventSearch.js'; describe('EventSearch', () => { it('finds the exact event by eventIndex when timestamps are duplicated', () => { diff --git a/log-viewer/src/core/utility/__tests__/Util.test.ts b/log-viewer/src/core/utility/__tests__/Util.test.ts index e9903a640..c03773da8 100644 --- a/log-viewer/src/core/utility/__tests__/Util.test.ts +++ b/log-viewer/src/core/utility/__tests__/Util.test.ts @@ -3,61 +3,41 @@ */ import { describe, expect, it } from '@jest/globals'; -import { computeWallClockMs, formatByteSize, formatWallClockTime } from '../Util.js'; +import { + computeWallClockMs, + formatByteSize, + formatDuration, + formatWallClockTime, +} from '../Util.js'; describe('formatWallClockTime', () => { - it('should format midnight as 00:00:00.000', () => { - expect(formatWallClockTime(0)).toBe('00:00:00.000'); - }); - - it('should format a mid-day time', () => { - // 14:30:05.122 = (14*3600 + 30*60 + 5) * 1000 + 122 = 52205122 - expect(formatWallClockTime(52205122)).toBe('14:30:05.122'); - }); - - it('should format end-of-day time', () => { - // 23:59:59.999 - expect(formatWallClockTime(86399999)).toBe('23:59:59.999'); - }); - - it('should pad single-digit hours, minutes, seconds', () => { - // 01:02:03.004 - expect(formatWallClockTime(3723004)).toBe('01:02:03.004'); - }); - - it('should handle exact seconds (no fractional ms)', () => { - // 10:00:00.000 - expect(formatWallClockTime(36000000)).toBe('10:00:00.000'); - }); - - it('should handle sub-millisecond precision by rounding', () => { - // 1000.5 ms → rounds to 1001 ms fraction → 00:00:01.001 - expect(formatWallClockTime(1000.5)).toBe('00:00:01.001'); + it.each([ + ['midnight', 0, '00:00:00.000'], + ['a mid-day time', 52205122, '14:30:05.122'], + ['the end of the day', 86399999, '23:59:59.999'], + ['single-digit hours, minutes and seconds, padded', 3723004, '01:02:03.004'], + ['exact seconds, with no fractional ms', 36000000, '10:00:00.000'], + ['sub-millisecond precision, rounded up', 1000.5, '00:00:01.001'], + ])('formats %s', (_case, ms, expected) => { + expect(formatWallClockTime(ms)).toBe(expected); }); }); describe('computeWallClockMs', () => { - it('should return startTime when event is the first event', () => { - const result = computeWallClockMs(37764600, 6329577, 6329577); - expect(result).toBe(37764600); - }); + // The first event: stamped FIRST_NS, on the wall clock at START_MS. + const START_MS = 37764600; + const FIRST_NS = 6329577; - it('should compute wall-clock for a later event', () => { - // Event is 1ms (1,000,000 ns) after first event - const result = computeWallClockMs(37764600, 6329577, 7329577); - expect(result).toBe(37764601); + it.each([ + ['the first event itself', FIRST_NS, START_MS], + ['an event 1ms later', FIRST_NS + 1_000_000, START_MS + 1], + ['an event 1s later', FIRST_NS + 1_000_000_000, START_MS + 1000], + ])('reads %s', (_case, timestamp, expected) => { + expect(computeWallClockMs(START_MS, FIRST_NS, timestamp)).toBe(expected); }); - it('should compute wall-clock for an event 1 second later', () => { - // 1 second = 1,000,000,000 ns - const result = computeWallClockMs(37764600, 6329577, 1006329577); - expect(result).toBe(37765600); - }); - - it('should handle fractional nanosecond differences', () => { - // 500,000 ns = 0.5 ms - const result = computeWallClockMs(0, 0, 500000); - expect(result).toBe(0.5); + it('keeps a fractional millisecond', () => { + expect(computeWallClockMs(0, 0, 500000)).toBe(0.5); }); }); @@ -80,3 +60,91 @@ describe('formatByteSize', () => { expect(formatByteSize(-1_500_000)).toBe('-1.5 MB'); }); }); + +describe('Format duration tests', () => { + it('Shows ms with decimals for very small values (sub-millisecond)', () => { + expect(formatDuration(5)).toBe('0 ms'); // 0.000005 ms rounds to 0 + expect(formatDuration(50)).toBe('0 ms'); // 0.00005 ms rounds to 0 + expect(formatDuration(500)).toBe('0.001 ms'); + expect(formatDuration(1000)).toBe('0.001 ms'); + expect(formatDuration(5000)).toBe('0.005 ms'); + expect(formatDuration(9999)).toBe('0.01 ms'); + expect(formatDuration(10000)).toBe('0.01 ms'); + expect(formatDuration(50000)).toBe('0.05 ms'); + expect(formatDuration(99999)).toBe('0.1 ms'); + }); + + it('handles ms duration', () => { + expect(formatDuration(100_000)).toBe('0.1 ms'); + expect(formatDuration(500_000)).toBe('0.5 ms'); + expect(formatDuration(1_000_000)).toBe('1 ms'); + expect(formatDuration(1_234_567)).toBe('1.23 ms'); + expect(formatDuration(9_999_999)).toBe('10 ms'); + expect(formatDuration(10_000_000)).toBe('10 ms'); + expect(formatDuration(99_999_999)).toBe('100 ms'); + expect(formatDuration(100_000_000)).toBe('100 ms'); + expect(formatDuration(999_000_000)).toBe('999 ms'); + }); + + it('handles zero duration', () => { + expect(formatDuration(0)).toBe('0 ms'); + }); + + it('handles seconds', () => { + expect(formatDuration(5_000_000_000)).toBe('5 s'); + expect(formatDuration(59_500_000_000)).toBe('59.5 s'); + }); + + it('handles minutes and seconds', () => { + expect(formatDuration(60_000_000_000)).toBe('1m'); + expect(formatDuration(125_000_000_000)).toBe('2m 5s'); + expect(formatDuration(125_500_000_000)).toBe('2m 5.5s'); + }); + + it('handles remove trailing 0 for all units types', () => { + expect(formatDuration(5000)).toBe('0.005 ms'); + expect(formatDuration(100_000)).toBe('0.1 ms'); + expect(formatDuration(5_000_000_000)).toBe('5 s'); + expect(formatDuration(60_000_000_000)).toBe('1m'); + }); + + it('handles rounding to appropriate precision', () => { + // sub-milliseconds (up to 3 decimal places) + expect(formatDuration(1234)).toBe('0.001 ms'); + expect(formatDuration(9876)).toBe('0.01 ms'); + + // milliseconds (up to 2 decimal places) + expect(formatDuration(1_234_567)).toBe('1.23 ms'); + expect(formatDuration(9_876_543)).toBe('9.88 ms'); + + // seconds (up to 2 decimal places) + expect(formatDuration(1_234_567_890)).toBe('1.23 s'); + expect(formatDuration(9_876_543_210)).toBe('9.88 s'); + }); + + it('rounds to 1dp for min and s', () => { + // minutes with fractional seconds + expect(formatDuration(125_670_000_000)).toBe('2m 5.7s'); + }); + + describe('compact option', () => { + it('omits spaces for milliseconds', () => { + expect(formatDuration(0, { compact: true })).toBe('0ms'); + expect(formatDuration(50000, { compact: true })).toBe('0.05ms'); + expect(formatDuration(1_000_000, { compact: true })).toBe('1ms'); + expect(formatDuration(1_234_567, { compact: true })).toBe('1.23ms'); + expect(formatDuration(100_000_000, { compact: true })).toBe('100ms'); + }); + + it('omits spaces for seconds', () => { + expect(formatDuration(5_000_000_000, { compact: true })).toBe('5s'); + expect(formatDuration(59_500_000_000, { compact: true })).toBe('59.5s'); + }); + + it('omits spaces for minutes', () => { + expect(formatDuration(60_000_000_000, { compact: true })).toBe('1m'); + expect(formatDuration(125_000_000_000, { compact: true })).toBe('2m5s'); + expect(formatDuration(125_500_000_000, { compact: true })).toBe('2m5.5s'); + }); + }); +}); diff --git a/log-viewer/src/features/analysis/components/__tests__/AnalysisView.test.ts b/log-viewer/src/features/analysis/components/__tests__/AnalysisView.test.ts index 453398113..82a95f1f4 100644 --- a/log-viewer/src/features/analysis/components/__tests__/AnalysisView.test.ts +++ b/log-viewer/src/features/analysis/components/__tests__/AnalysisView.test.ts @@ -29,10 +29,6 @@ jest.mock('../../../call-tree/components/BottomUpTable.js', () => ({ tableBuilt: new Promise(() => {}), }), })); -// vscode-button needs ElementInternals.setFormValue (absent in jsdom). -jest.mock('#vscode-elements/vscode-button.js', () => ({})); -jest.mock('#vscode-elements/vscode-option.js', () => ({})); -jest.mock('#vscode-elements/vscode-toolbar-button.js', () => ({})); import { eventBus, diff --git a/log-viewer/src/features/analysis/components/__tests__/LogDiagnosticsView.test.ts b/log-viewer/src/features/analysis/components/__tests__/LogDiagnosticsView.test.ts index 78ec13ff1..4a8d3230a 100644 --- a/log-viewer/src/features/analysis/components/__tests__/LogDiagnosticsView.test.ts +++ b/log-viewer/src/features/analysis/components/__tests__/LogDiagnosticsView.test.ts @@ -5,10 +5,9 @@ */ import { beforeEach, describe, expect, it } from '@jest/globals'; +import { mountElement } from '../../../../__tests__/helpers/mount.js'; import type { LogDiagnostics } from '../../services/LogDiagnostics.js'; -jest.mock('#vscode-elements/vscode-icon.js', () => ({})); - let result: LogDiagnostics = { diagnostics: [], queryPlansKnown: true, @@ -36,12 +35,10 @@ const loadLog = (element: HTMLElementTagNameMap['log-diagnostics']) => { }; const view = async (scope?: { instances: number[] }) => { - const element = document.createElement('log-diagnostics'); - if (scope) { - element.instances = scope.instances; - } - document.body.append(element); - await element.updateComplete; + const element = await mountElement( + 'log-diagnostics', + scope ? { instances: scope.instances } : {}, + ); // One more turn: the findings arrive from an async call in connectedCallback. await element.updateComplete; return element; @@ -65,7 +62,6 @@ const text = (element: HTMLElement, selector: string) => describe('log-diagnostics', () => { beforeEach(() => { - document.body.replaceChildren(); result = { diagnostics: [], queryPlansKnown: true, diff --git a/log-viewer/src/features/analysis/components/__tests__/SelfTimeSpreadView.test.ts b/log-viewer/src/features/analysis/components/__tests__/SelfTimeSpreadView.test.ts index b1d3b7ed3..27dfbd1bd 100644 --- a/log-viewer/src/features/analysis/components/__tests__/SelfTimeSpreadView.test.ts +++ b/log-viewer/src/features/analysis/components/__tests__/SelfTimeSpreadView.test.ts @@ -5,6 +5,7 @@ */ import { beforeEach, describe, expect, it } from '@jest/globals'; +import { mountElement } from '../../../../__tests__/helpers/mount.js'; import type { LogStore } from '../../../../core/log/LogStore.js'; import type { SelfTimeSpread } from '../../services/SelfTimeSpread.js'; @@ -39,20 +40,16 @@ const spreadOf = (overrides: Partial = {}): SelfTimeSpread => ({ ...overrides, }); -const view = async () => { - const element = document.createElement('self-time-spread'); - element.logStore = { log: {} } as unknown as LogStore; - document.body.append(element); - await element.updateComplete; - return element; -}; +const view = () => + mountElement('self-time-spread', { + logStore: { log: {} } as unknown as LogStore, + }); const text = (element: Element, selector: string) => element.shadowRoot!.querySelector(selector)?.textContent?.replace(/\s+/g, ' ').trim(); describe('self-time-spread', () => { beforeEach(() => { - document.body.replaceChildren(); spread = null; }); diff --git a/log-viewer/src/features/analysis/services/__tests__/CallStackSum.test.ts b/log-viewer/src/features/analysis/services/__tests__/CallStackSum.test.ts index e2c9dab01..2d1d99b0b 100644 --- a/log-viewer/src/features/analysis/services/__tests__/CallStackSum.test.ts +++ b/log-viewer/src/features/analysis/services/__tests__/CallStackSum.test.ts @@ -1,8 +1,7 @@ /** * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -import { beforeEach, describe, expect, it } from '@jest/globals'; -import type { LogEvent } from 'apex-log-parser'; +import { describe, expect, it } from '@jest/globals'; import { sumDurationTotalForRootEvents, @@ -10,47 +9,9 @@ import { sumTotalForRootEvents, } from '../CallStackSum.js'; import type { Metric } from '../RowGrouper.js'; - -type EventOptions = { - text: string; - total: number; - heapTotal?: number; - parent?: LogEvent | null; -}; - -let nextTimestamp = 1; - -function createEvent(options: EventOptions): LogEvent { - const event = { - parent: options.parent ?? null, - children: [], - type: 'METHOD_ENTRY' as LogEvent['type'], - text: options.text, - namespace: 'default', - timestamp: nextTimestamp++, - duration: { self: 0, total: options.total }, - heapAllocated: { self: 0, total: options.heapTotal ?? 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 }, - thrownCount: { self: 0, total: 0 }, - } as unknown as LogEvent; - - if (options.parent) { - options.parent.children.push(event); - } - - return event; -} +import { createEvent } from '../../../../__tests__/helpers/events.js'; describe('sumDurationTotalForRootEvents', () => { - beforeEach(() => { - nextTimestamp = 1; - }); - it('counts each call-stack root once and skips events whose ancestors are visible', () => { // ParentA(80) → LeafA(80); ParentB(30) → LeafB(30). // Naive sum = 80+30+80+30 = 220 (double-counts). @@ -76,10 +37,6 @@ describe('sumDurationTotalForRootEvents', () => { }); describe('sumTotalForRootEvents (generic accessor)', () => { - beforeEach(() => { - nextTimestamp = 1; - }); - it('dedups by call stack for an arbitrary field (heap total)', () => { // Parent heap 100 (subtree total), its leaf 100; another parent 30, its leaf 30. // Naive sum = 260; root-only dedup = 130. Proves the BottomUp heap-total footer @@ -96,10 +53,6 @@ describe('sumTotalForRootEvents (generic accessor)', () => { }); describe('sumRootNodesOnly (Metric adapter)', () => { - beforeEach(() => { - nextTimestamp = 1; - }); - it('extracts Metric.nodes and applies the root-only sum', () => { const parent = createEvent({ text: 'parent', total: 100 }); const child = createEvent({ text: 'child', total: 60, parent }); diff --git a/log-viewer/src/features/analysis/services/__tests__/RowGrouper.test.ts b/log-viewer/src/features/analysis/services/__tests__/RowGrouper.test.ts index 64fb8be64..c60a2588e 100644 --- a/log-viewer/src/features/analysis/services/__tests__/RowGrouper.test.ts +++ b/log-viewer/src/features/analysis/services/__tests__/RowGrouper.test.ts @@ -1,74 +1,12 @@ /** * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -import { beforeEach, describe, expect, it } from '@jest/globals'; -import type { LogEvent } from 'apex-log-parser'; +import { describe, expect, it } from '@jest/globals'; +import { createEvent } from '../../../../__tests__/helpers/events.js'; import { group } from '../RowGrouper.js'; -type EventOptions = { - text: string; - self: number; - total: number; - parent?: LogEvent | null; - type?: string; - namespace?: string; - dmlSelf?: number; - dmlTotal?: number; - soqlSelf?: number; - soqlTotal?: number; - thrown?: number; -}; - -let nextTimestamp = 1; - -function createEvent(options: EventOptions): LogEvent { - const event = { - logParser: null, - parent: options.parent ?? null, - children: [], - type: (options.type ?? 'METHOD_ENTRY') as LogEvent['type'], - logLine: '', - text: options.text, - acceptsText: false, - isExit: false, - isParent: false, - isTruncated: false, - nextLineIsExit: false, - lineNumber: null, - namespace: options.namespace ?? 'default', - hasValidSymbols: true, - suffix: null, - discontinuity: false, - timestamp: nextTimestamp++, - exitStamp: null, - category: '', - debugCategory: '', - debugLevel: '', - cpuType: '', - duration: { self: options.self, total: options.total }, - dmlRowCount: { self: 0, total: 0 }, - soqlRowCount: { self: 0, total: 0 }, - soslRowCount: { self: 0, total: 0 }, - dmlCount: { self: options.dmlSelf ?? 0, total: options.dmlTotal ?? 0 }, - soqlCount: { self: options.soqlSelf ?? 0, total: options.soqlTotal ?? 0 }, - soslCount: { self: 0, total: 0 }, - thrownCount: { self: options.thrown ?? 0, total: options.thrown ?? 0 }, - exitTypes: [], - } as unknown as LogEvent; - - if (options.parent) { - options.parent.children.push(event); - } - - return event; -} - describe('RowGrouper.group', () => { - beforeEach(() => { - nextTimestamp = 1; - }); - it('includes zero-time leaves so DML/SOQL/exception counts and call counts roll up', () => { const root = createEvent({ text: 'LOG_ROOT', self: 0, total: 0, type: 'EXECUTION_STARTED' }); createEvent({ diff --git a/log-viewer/src/features/app/__tests__/escapeDeselect.test.ts b/log-viewer/src/features/app/__tests__/escapeDeselect.test.ts index cbc938d12..07482402c 100644 --- a/log-viewer/src/features/app/__tests__/escapeDeselect.test.ts +++ b/log-viewer/src/features/app/__tests__/escapeDeselect.test.ts @@ -62,10 +62,6 @@ function toggleFor(panelId: string): HTMLElement { } describe('isDeselectEscape', () => { - afterEach(() => { - document.body.innerHTML = ''; - }); - it('accepts a plain Escape', () => { expect(judge(document.body)).toBe(true); }); diff --git a/log-viewer/src/features/app/__tests__/logLoadFailure.test.ts b/log-viewer/src/features/app/__tests__/logLoadFailure.test.ts index 717ec8fac..8e855f458 100644 --- a/log-viewer/src/features/app/__tests__/logLoadFailure.test.ts +++ b/log-viewer/src/features/app/__tests__/logLoadFailure.test.ts @@ -5,11 +5,6 @@ */ import { describe, expect, it } from '@jest/globals'; -// jsdom can't run the real elements (they read document.baseURI). -jest.mock('#vscode-elements/vscode-icon.js', () => ({})); -jest.mock('#vscode-elements/vscode-tabs.js', () => ({})); -jest.mock('#vscode-elements/vscode-tab-header.js', () => ({})); -jest.mock('#vscode-elements/vscode-tab-panel.js', () => ({})); // The header and the inspector pull in every grid, and Tabulator needs a real DOM. jest.mock('../AppHeader.js', () => ({})); jest.mock('../../../components/LogInspector.js', () => ({})); @@ -18,6 +13,7 @@ jest.mock('../../../core/messaging/VSCodeExtensionMessenger.js', () => ({ VSCodeExtensionMessenger: { listen: jest.fn(() => () => {}) }, })); +import { mountElement } from '../../../__tests__/helpers/mount.js'; import type { LogViewer } from '../LogViewer.js'; import '../LogViewer.js'; @@ -32,12 +28,7 @@ const MINIMAL_LOG = [ '12:00:00.1 (100)|EXECUTION_FINISHED', ].join('\n'); -async function mount(): Promise { - const el = document.createElement('log-viewer') as LogViewer; - document.body.appendChild(el); - await el.updateComplete; - return el; -} +const mount = (): Promise => mountElement('log-viewer'); describe('a log that could not be read', () => { it('reports a failed load rather than an empty log', async () => { diff --git a/log-viewer/src/features/call-tree/utils/__tests__/Aggregation.test.ts b/log-viewer/src/features/call-tree/utils/__tests__/Aggregation.test.ts index 5100079cd..4d2b7a0ca 100644 --- a/log-viewer/src/features/call-tree/utils/__tests__/Aggregation.test.ts +++ b/log-viewer/src/features/call-tree/utils/__tests__/Aggregation.test.ts @@ -1,9 +1,10 @@ /** * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -import { beforeEach, describe, expect, it } from '@jest/globals'; +import { describe, expect, it } from '@jest/globals'; import type { LogEvent } from 'apex-log-parser'; +import { createEvent } from '../../../../__tests__/helpers/events.js'; import { outermostEvents } from '../../../../core/utility/EventTree.js'; import { toAggregatedCallTree, @@ -13,76 +14,6 @@ import { } from '../Aggregation.js'; import { KeyPathIds } from '../../../../core/log/keyPathIds.js'; -type EventOptions = { - text: string; - self: number; - total: number; - parent?: LogEvent | null; - type?: string; - dmlSelf?: number; - dmlTotal?: number; - soqlSelf?: number; - soqlTotal?: number; - soslSelf?: number; - soslTotal?: number; - dmlRowSelf?: number; - dmlRowTotal?: number; - soqlRowSelf?: number; - soqlRowTotal?: number; - soslRowSelf?: number; - soslRowTotal?: number; - thrown?: number; - heapSelf?: number; - heapTotal?: number; -}; - -let nextTimestamp = 1; - -function createEvent(options: EventOptions): LogEvent { - const event = { - logParser: null, - parent: options.parent ?? null, - children: [], - type: (options.type ?? 'METHOD_ENTRY') as LogEvent['type'], - logLine: '', - text: options.text, - acceptsText: false, - isExit: false, - isParent: false, - isTruncated: false, - nextLineIsExit: false, - lineNumber: null, - namespace: 'default', - hasValidSymbols: true, - suffix: null, - discontinuity: false, - timestamp: nextTimestamp++, - exitStamp: null, - category: '', - debugCategory: '', - debugLevel: '', - cpuType: '', - duration: { self: options.self, total: options.total }, - dmlRowCount: { self: options.dmlRowSelf ?? 0, total: options.dmlRowTotal ?? 0 }, - soqlRowCount: { self: options.soqlRowSelf ?? 0, total: options.soqlRowTotal ?? 0 }, - soslRowCount: { self: options.soslRowSelf ?? 0, total: options.soslRowTotal ?? 0 }, - dmlCount: { self: options.dmlSelf ?? 0, total: options.dmlTotal ?? 0 }, - soqlCount: { self: options.soqlSelf ?? 0, total: options.soqlTotal ?? 0 }, - soslCount: { self: options.soslSelf ?? 0, total: options.soslTotal ?? 0 }, - thrownCount: { self: options.thrown ?? 0, total: options.thrown ?? 0 }, - heapAllocated: { self: options.heapSelf ?? 0, total: options.heapTotal ?? 0 }, - heapGross: { self: 0, total: 0 }, - heapPeak: 0, - exitTypes: [], - } as unknown as LogEvent; - - if (options.parent) { - options.parent.children.push(event); - } - - return event; -} - /** A table per build: the fixtures below reuse event indexes for different * frames, so one frame's key must not be read back for another. */ function bottomUpOf(children: LogEvent[]): BottomUpRow[] { @@ -200,10 +131,6 @@ function sumTraceSelfTime(events: LogEvent[]): number { } describe('toBottomUpTree', () => { - beforeEach(() => { - nextTimestamp = 1; - }); - it('attributes caller rows using the current node contribution, not the caller event metrics', () => { const root = createEvent({ text: 'LOG_ROOT', self: 0, total: 0, type: 'EXECUTION_STARTED' }); const parentA = createEvent({ @@ -1031,10 +958,6 @@ describe('toBottomUpTree', () => { }); describe('bottom-up caller row scope', () => { - beforeEach(() => { - nextTimestamp = 1; - }); - /** A -> B -> A on one branch, A -> C -> A on the other, so a caller bucket * reaches a strict subset of the root bucket's occurrences. */ function recursiveRoot(): LogEvent { @@ -1107,10 +1030,6 @@ describe('bottom-up caller row scope', () => { }); describe('toAggregatedCallTree', () => { - beforeEach(() => { - nextTimestamp = 1; - }); - it('includes zero-time frames so DML/SOQL/exception counts and callCount roll up', () => { const root = createEvent({ text: 'LOG_ROOT', self: 0, total: 0, type: 'EXECUTION_STARTED' }); createEvent({ @@ -1130,10 +1049,6 @@ describe('toAggregatedCallTree', () => { }); describe('_hasDetailsDeep precomputation', () => { - beforeEach(() => { - nextTimestamp = 1; - }); - it('aggregated: marks zero-time leaf with no excluded type as not significant', () => { const root = createEvent({ text: 'LOG_ROOT', self: 0, total: 0, type: 'EXECUTION_STARTED' }); createEvent({ text: 'NoTime', self: 0, total: 0, parent: root }); diff --git a/log-viewer/src/features/call-tree/utils/__tests__/eventText.test.ts b/log-viewer/src/features/call-tree/utils/__tests__/eventText.test.ts index 46865eaff..8d40eb240 100644 --- a/log-viewer/src/features/call-tree/utils/__tests__/eventText.test.ts +++ b/log-viewer/src/features/call-tree/utils/__tests__/eventText.test.ts @@ -2,56 +2,11 @@ * Copyright (c) 2026 Certinia Inc. All rights reserved. */ import { describe, expect, it } from '@jest/globals'; -import type { GovernorLimits, LogEvent } from 'apex-log-parser'; +import type { GovernorLimits } from 'apex-log-parser'; +import { createEvent } from '../../../../__tests__/helpers/events.js'; import { eventLabel, eventName, formatCallStack, formatEventDetails } from '../eventText.js'; -type EventOptions = { - text: string; - type?: string; - self?: number; - total?: number; - exitStamp?: number | null; - suffix?: string | null; - parent?: LogEvent | null; - soqlTotal?: number; - soqlSelf?: number; - soqlRowTotal?: number; - soqlRowSelf?: number; - dmlTotal?: number; - dmlSelf?: number; - soslRowTotal?: number; - soslRowSelf?: number; -}; - -let nextTimestamp = 1; - -function createEvent(options: EventOptions): LogEvent { - const event = { - parent: options.parent ?? null, - children: [], - type: (options.type ?? 'METHOD_ENTRY') as LogEvent['type'], - text: options.text, - suffix: options.suffix ?? null, - namespace: 'default', - timestamp: nextTimestamp++, - exitStamp: options.exitStamp === undefined ? 1000 : options.exitStamp, - cpuType: '', - duration: { self: options.self ?? 0, total: options.total ?? 0 }, - dmlRowCount: { self: 0, total: 0 }, - soqlRowCount: { self: options.soqlRowSelf ?? 0, total: options.soqlRowTotal ?? 0 }, - soslRowCount: { self: options.soslRowSelf ?? 0, total: options.soslRowTotal ?? 0 }, - dmlCount: { self: options.dmlSelf ?? 0, total: options.dmlTotal ?? 0 }, - soqlCount: { self: options.soqlSelf ?? 0, total: options.soqlTotal ?? 0 }, - soslCount: { self: 0, total: 0 }, - } as unknown as LogEvent; - - if (options.parent) { - options.parent.children.push(event); - } - return event; -} - const limits = { soqlQueries: { used: 1, limit: 100 }, queryRows: { used: 300, limit: 50000 }, @@ -110,6 +65,8 @@ describe('formatEventDetails', () => { text: 'MyClass.run()', total: 5_000_000, self: 2_000_000, + // The duration is only reported for a frame that exited. + exitStamp: 1000, }); expect(formatEventDetails(event)).toBe( ['Name: MyClass.run()', 'Type: METHOD_ENTRY', 'Duration: 5 ms (self 2 ms)'].join('\n'), @@ -126,6 +83,7 @@ describe('formatEventDetails', () => { text: 'SELECT Id FROM Account', type: 'SOQL_EXECUTE_BEGIN', total: 1_000_000, + exitStamp: 1000, soqlTotal: 1, soqlSelf: 1, soqlRowTotal: 300, diff --git a/log-viewer/src/features/database/components/__tests__/DatabaseOverview.test.ts b/log-viewer/src/features/database/components/__tests__/DatabaseOverview.test.ts index 4f7af333b..5db46afba 100644 --- a/log-viewer/src/features/database/components/__tests__/DatabaseOverview.test.ts +++ b/log-viewer/src/features/database/components/__tests__/DatabaseOverview.test.ts @@ -5,8 +5,8 @@ */ import { beforeEach, describe, expect, it } from '@jest/globals'; import type { ApexLog } from 'apex-log-parser'; -import type { LitElement } from 'lit'; +import { mountElement } from '../../../../__tests__/helpers/mount.js'; import type { StackedTimeBar } from '../../../../components/StackedTimeBar.js'; import type { LogStore } from '../../../../core/log/LogStore.js'; import type { @@ -20,8 +20,6 @@ import type { const apexLog = { namespaces: ['pkg', 'trigPkg'] } as unknown as ApexLog; let overview: DatabaseOverview | null = null; -// jsdom has no stylesheet for the icon element to adopt, so it is left unregistered. -jest.mock('#vscode-elements/vscode-icon.js', () => ({})); // The tabulator ESM build and its module registrations don't load under jest; the // tree's grid is never built here, only its row mapper is exercised. jest.mock('tabulator-tables', () => ({ @@ -196,18 +194,12 @@ const namespaceSplit = (): DatabaseBreakdown[] => [ }, ]; -async function mount( - tag: K, -): Promise { - const element = document.createElement(tag); +const mount = (tag: K) => // No provider in the test, so the consumed store is assigned straight on. - (element as unknown as { logStore: LogStore }).logStore = { - log: apexLog, - } as unknown as LogStore; - document.body.append(element); - await (element as LitElement).updateComplete; - return element as HTMLElementTagNameMap[K] & LitElement; -} + // Both elements hold a `logStore`, which a generic tag cannot prove on its own. + mountElement(tag, { + logStore: { log: apexLog } as unknown as LogStore, + } as Partial); const texts = (element: Element, selector: string) => [...(element.shadowRoot?.querySelectorAll(selector) ?? [])].map((node) => @@ -267,7 +259,6 @@ describe('databaseTreeRows', () => { describe('database-concentration', () => { beforeEach(() => { - document.body.replaceChildren(); overview = null; }); @@ -390,7 +381,6 @@ describe('database-concentration', () => { describe('database-namespaces', () => { beforeEach(() => { - document.body.replaceChildren(); overview = null; }); diff --git a/log-viewer/src/features/database/components/__tests__/DatabaseRowBudget.test.ts b/log-viewer/src/features/database/components/__tests__/DatabaseRowBudget.test.ts index e74f6a1b9..bdd13672c 100644 --- a/log-viewer/src/features/database/components/__tests__/DatabaseRowBudget.test.ts +++ b/log-viewer/src/features/database/components/__tests__/DatabaseRowBudget.test.ts @@ -5,8 +5,8 @@ */ import { beforeEach, describe, expect, it } from '@jest/globals'; import type { ApexLog } from 'apex-log-parser'; -import type { LitElement } from 'lit'; +import { mountElement } from '../../../../__tests__/helpers/mount.js'; import type { StackedTimeBar } from '../../../../components/StackedTimeBar.js'; import type { LogStore } from '../../../../core/log/LogStore.js'; import type { RowBudget, RowBudgets } from '../../services/rowBudget.js'; @@ -18,8 +18,8 @@ let budgets: RowBudgets; jest.mock('../../services/rowBudget.js', () => ({ rowBudgets: () => budgets, })); -jest.mock('#vscode-elements/vscode-icon.js', () => ({})); +import type { DatabaseRowBudget } from '../DatabaseRowBudget.js'; import '../DatabaseRowBudget.js'; import { settledNote } from '../../../../components/__tests__/sectionTestUtils.js'; @@ -67,16 +67,11 @@ const withBudgets = (...changes: Partial[]): RowBudgets => { }; }; -async function mount(): Promise { - const element = document.createElement('database-rows'); +const mount = (): Promise => // No provider in the test, so the consumed store is assigned straight on. - (element as unknown as { logStore: LogStore }).logStore = { - log: apexLog, - } as unknown as LogStore; - document.body.append(element); - await element.updateComplete; - return element; -} + mountElement('database-rows', { + logStore: { log: apexLog } as unknown as LogStore, + }); const texts = (element: Element, selector: string) => [...(element.shadowRoot?.querySelectorAll(selector) ?? [])].map((node) => @@ -96,7 +91,6 @@ const barOf = (element: Element, label: string) => bars(element).find((bar) => bar.getAttribute('label') === label); beforeEach(() => { - document.body.replaceChildren(); budgets = full(); }); diff --git a/log-viewer/src/features/database/components/__tests__/DatabaseView.test.ts b/log-viewer/src/features/database/components/__tests__/DatabaseView.test.ts index ee8b4f006..bcf9efe47 100644 --- a/log-viewer/src/features/database/components/__tests__/DatabaseView.test.ts +++ b/log-viewer/src/features/database/components/__tests__/DatabaseView.test.ts @@ -4,6 +4,7 @@ * @jest-environment jsdom */ import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'; +import type { LitElement } from 'lit'; import { eventBus, @@ -21,6 +22,7 @@ jest.mock('../GovernorSummary.js', () => ({})); jest.mock('../DatabaseSection.js', () => ({})); import '../DatabaseView.js'; +import { mountElement } from '../../../../__tests__/helpers/mount.js'; /** The slice of a grid DatabaseView drives, standing in for the real element. */ interface FakeGrid extends HTMLElement { @@ -49,16 +51,13 @@ function fakeGrid(tag: string, owns: number | null = null): FakeGrid { } describe('database-view selection', () => { - let view: HTMLElement; + let view: LitElement; let grids: Record; let seen: Array<{ source: DetailSource; selection: DetailSelection | null }>; let off: () => void; beforeEach(async () => { - document.body.replaceChildren(); - view = document.createElement('database-view'); - document.body.append(view); - await (view as HTMLElement & { updateComplete: Promise }).updateComplete; + view = await mountElement('database-view'); grids = { dml: fakeGrid('dml-view'), soql: fakeGrid('soql-view', 42), @@ -71,7 +70,6 @@ describe('database-view selection', () => { afterEach(() => { off(); - document.body.replaceChildren(); }); /** The grids report upward; DatabaseView alone turns that into a selection. */ @@ -181,17 +179,10 @@ describe('database-view selection', () => { }); describe('database-view find totals', () => { - let view: HTMLElement & { updateComplete: Promise }; + let view: LitElement; beforeEach(async () => { - document.body.replaceChildren(); - view = document.createElement('database-view') as typeof view; - document.body.append(view); - await view.updateComplete; - }); - - afterEach(() => { - document.body.replaceChildren(); + view = await mountElement('database-view'); }); /** The totals DatabaseView rolls up to the find widget while `run` happens. */ diff --git a/log-viewer/src/features/database/components/__tests__/GovernorSummary.test.ts b/log-viewer/src/features/database/components/__tests__/GovernorSummary.test.ts index e83650103..35d0f1031 100644 --- a/log-viewer/src/features/database/components/__tests__/GovernorSummary.test.ts +++ b/log-viewer/src/features/database/components/__tests__/GovernorSummary.test.ts @@ -3,26 +3,23 @@ * * @jest-environment jsdom */ -import { beforeEach, describe, expect, it } from '@jest/globals'; +import { describe, expect, it } from '@jest/globals'; import { NO_LIMIT_FOR_METRIC_TEXT } from '../../../../components/governorCopy.js'; import { formatInteger } from '../../../../core/utility/Util.js'; -import type { GaugeMetric, GovernorSummary } from '../GovernorSummary.js'; +import { mountElement } from '../../../../__tests__/helpers/mount.js'; +import { + GOVERNOR_WARN_PERCENT, + governorTier, + type GaugeMetric, + type GovernorSummary, +} from '../GovernorSummary.js'; import '../GovernorSummary.js'; -const strip = async (metrics: GaugeMetric[]) => { - const element = document.createElement('governor-summary') as GovernorSummary; - element.metrics = metrics; - document.body.append(element); - await element.updateComplete; - return element; -}; +const strip = (metrics: GaugeMetric[]) => + mountElement('governor-summary', { metrics }); describe('governor-summary', () => { - beforeEach(() => { - document.body.replaceChildren(); - }); - describe('a limit the log reported', () => { it('meters the value against it', async () => { const element = await strip([ @@ -96,21 +93,29 @@ describe('governor-summary', () => { return [...classes].find((name) => name.startsWith('gauge__value--')) ?? null; }; - it('reads safe below the warn threshold', async () => { - expect(await tierOf(79, 100)).toBe('gauge__value--safe'); - }); - - it('warns from the threshold', async () => { + it('puts the tier the policy gives on the figure', async () => { expect(await tierOf(80, 100)).toBe('gauge__value--warn'); }); - it('reads danger at the limit', async () => { - expect(await tierOf(100, 100)).toBe('gauge__value--danger'); - }); - // No limit, so no tier to report against. it('takes none where the log reported no limit', async () => { expect(await tierOf(12, 0)).toBeNull(); }); }); }); + +// The boundaries of the policy its three importers read, stated here and nowhere +// else. A component proves it applies what it is given, not where the bands fall. +// DatabaseMetricCard.ts:29 does not import it — it holds a fourth copy, on a bare 80. +describe('governorTier', () => { + it.each([ + [0, 'safe'], + [GOVERNOR_WARN_PERCENT - 0.1, 'safe'], + [GOVERNOR_WARN_PERCENT, 'warn'], + [99.9, 'warn'], + [100, 'danger'], + [250, 'danger'], + ])('reads %p percent as %s', (percent, expected) => { + expect(governorTier(percent)).toBe(expected); + }); +}); diff --git a/log-viewer/src/features/notifications/__tests__/IssueList.test.ts b/log-viewer/src/features/notifications/__tests__/IssueList.test.ts index 47f73cb5d..a5d15211f 100644 --- a/log-viewer/src/features/notifications/__tests__/IssueList.test.ts +++ b/log-viewer/src/features/notifications/__tests__/IssueList.test.ts @@ -5,9 +5,7 @@ */ import { afterEach, beforeAll, describe, expect, it } from '@jest/globals'; -// jsdom can't run the real elements (they read document.baseURI / setFormValue). -jest.mock('#vscode-elements/vscode-icon.js', () => ({})); - +import { mountElement } from '../../../__tests__/helpers/mount.js'; import type { IssueAction, IssueSeverity, LogIssue } from '../types.js'; import type { IssueList } from '../components/IssueList.js'; @@ -30,13 +28,8 @@ function issue( }; } -async function mount(issues: readonly LogIssue[]): Promise { - const el = document.createElement('issue-list') as IssueList; - el.issues = issues; - document.body.appendChild(el); - await el.updateComplete; - return el; -} +const mount = (issues: readonly LogIssue[]): Promise => + mountElement('issue-list', { issues }); function cards(el: IssueList): HTMLElement[] { return Array.from(el.shadowRoot?.querySelectorAll('.issue') ?? []); diff --git a/log-viewer/src/features/notifications/__tests__/NotificationCentre.test.ts b/log-viewer/src/features/notifications/__tests__/NotificationCentre.test.ts index 5496a0f07..8e9a936df 100644 --- a/log-viewer/src/features/notifications/__tests__/NotificationCentre.test.ts +++ b/log-viewer/src/features/notifications/__tests__/NotificationCentre.test.ts @@ -5,10 +5,7 @@ */ import { describe, expect, it } from '@jest/globals'; -// jsdom can't run the real elements (they read document.baseURI / setFormValue). -jest.mock('#vscode-elements/vscode-icon.js', () => ({})); -jest.mock('#vscode-elements/vscode-button.js', () => ({})); - +import { mountElement } from '../../../__tests__/helpers/mount.js'; import type { IssueSeverity, LogIssue } from '../types.js'; import type { NotificationCentre } from '../components/NotificationCentre.js'; @@ -26,13 +23,8 @@ function issue(severity: IssueSeverity): LogIssue { }; } -async function mount(issues: readonly LogIssue[]): Promise { - const el = document.createElement('notification-centre') as NotificationCentre; - el.issues = issues; - document.body.appendChild(el); - await el.updateComplete; - return el; -} +const mount = (issues: readonly LogIssue[]): Promise => + mountElement('notification-centre', { issues }); function badge(el: NotificationCentre): HTMLElement | null { return el.shadowRoot?.querySelector('.header-control__badge') ?? null; diff --git a/log-viewer/src/__tests__/soql/SOQLLinter.test.ts b/log-viewer/src/features/soql/services/__tests__/SOQLLinter.test.ts similarity index 83% rename from log-viewer/src/__tests__/soql/SOQLLinter.test.ts rename to log-viewer/src/features/soql/services/__tests__/SOQLLinter.test.ts index 90311b9ec..845936e6f 100644 --- a/log-viewer/src/__tests__/soql/SOQLLinter.test.ts +++ b/log-viewer/src/features/soql/services/__tests__/SOQLLinter.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from '@jest/globals'; import { ApexLogParser, LogEvent } from 'apex-log-parser'; -import { SOQLLinter } from '../../features/soql/services/SOQLLinter.js'; +import { SOQLLinter } from '../SOQLLinter.js'; class DummySOQLLine extends LogEvent { constructor(parser: ApexLogParser, parts: string[]) { @@ -88,41 +88,13 @@ describe('Negative Filter Operator Rule tests', () => { severity: 'Warning', }; - it('!= : should return rule', async () => { - const soql = "SELECT Id FROM ANOBJECT__c WHERE Name != 'A Name'"; - - const results = await new SOQLLinter().lint(soql); - - expect(results).toEqual([negativeFilterRule]); - }); - - it('<> : should return rule', async () => { - const soql = "SELECT Id FROM ANOBJECT__c WHERE Name <> 'A Name'"; - - const results = await new SOQLLinter().lint(soql); - - expect(results).toEqual([negativeFilterRule]); - }); - - it('EXCLUDES : should return rule', async () => { - const soql = "SELECT Id FROM ANOBJECT__c WHERE Name EXCLUDES ('A Name')"; - - const results = await new SOQLLinter().lint(soql); - - expect(results).toEqual([negativeFilterRule]); - }); - - it('NOT : should return rule', async () => { - const soql = "SELECT Id FROM ANOBJECT__c WHERE NOT Name = 'A Name'"; - - const results = await new SOQLLinter().lint(soql); - - expect(results).toEqual([negativeFilterRule]); - }); - - it('NOT IN : should return rule', async () => { - const soql = "SELECT Id FROM ANOBJECT__c WHERE Id NOT IN ('a0000000000aaaa')"; - + it.each([ + "SELECT Id FROM ANOBJECT__c WHERE Name != 'A Name'", + "SELECT Id FROM ANOBJECT__c WHERE Name <> 'A Name'", + "SELECT Id FROM ANOBJECT__c WHERE Name EXCLUDES ('A Name')", + "SELECT Id FROM ANOBJECT__c WHERE NOT Name = 'A Name'", + "SELECT Id FROM ANOBJECT__c WHERE Id NOT IN ('a0000000000aaaa')", + ])('should return rule for %s', async (soql) => { const results = await new SOQLLinter().lint(soql); expect(results).toEqual([negativeFilterRule]); diff --git a/log-viewer/src/__tests__/SOQLParser.test.ts b/log-viewer/src/features/soql/services/__tests__/SOQLParser.test.ts similarity index 96% rename from log-viewer/src/__tests__/SOQLParser.test.ts rename to log-viewer/src/features/soql/services/__tests__/SOQLParser.test.ts index 4455197f7..b291e96ae 100644 --- a/log-viewer/src/__tests__/SOQLParser.test.ts +++ b/log-viewer/src/features/soql/services/__tests__/SOQLParser.test.ts @@ -3,7 +3,7 @@ */ import { describe, expect, it } from '@jest/globals'; -import { SOQLParser, SyntaxException } from '../features/soql/services/SOQLParser.js'; +import { SOQLParser, SyntaxException } from '../SOQLParser.js'; describe('Analyse database tests', () => { it('throws on unparsable query', async () => { diff --git a/log-viewer/src/features/timeline/__tests__/TimelineKey.test.ts b/log-viewer/src/features/timeline/__tests__/TimelineKey.test.ts index 9fb041c3a..b797b77b8 100644 --- a/log-viewer/src/features/timeline/__tests__/TimelineKey.test.ts +++ b/log-viewer/src/features/timeline/__tests__/TimelineKey.test.ts @@ -5,19 +5,12 @@ */ import { describe, expect, it } from '@jest/globals'; -// jsdom can't run the real element (vscode-icon reads document.baseURI). -jest.mock('../../../components/OverflowList.js', () => ({})); - +import { mountElement } from '../../../__tests__/helpers/mount.js'; import type { TimelineKeyEntry, Timelinekey } from '../components/TimelineKey.js'; import '../components/TimelineKey.js'; -async function mount(entries: TimelineKeyEntry[]): Promise { - const el = document.createElement('timeline-key') as Timelinekey; - el.timelineKeys = entries; - document.body.appendChild(el); - await el.updateComplete; - return el; -} +const mount = (entries: TimelineKeyEntry[]): Promise => + mountElement('timeline-key', { timelineKeys: entries }); function chips(el: Timelinekey): HTMLElement[] { return [...(el.shadowRoot?.querySelectorAll('.chip') ?? [])]; diff --git a/log-viewer/src/features/timeline/__tests__/event-index.test.ts b/log-viewer/src/features/timeline/__tests__/event-index.test.ts index 265e70a2b..2dc7f98cf 100644 --- a/log-viewer/src/features/timeline/__tests__/event-index.test.ts +++ b/log-viewer/src/features/timeline/__tests__/event-index.test.ts @@ -14,8 +14,8 @@ import { describe, expect, it } from '@jest/globals'; import type { LogEvent } from 'apex-log-parser'; +import { makeViewport } from '../../../__tests__/helpers/viewport.js'; import { TimelineEventIndex } from '../optimised/TimelineEventIndex.js'; -import type { ViewportState } from '../types/flamechart.types.js'; describe('TimelineEventIndex', () => { /** @@ -37,23 +37,6 @@ describe('TimelineEventIndex', () => { } as unknown as LogEvent; } - /** - * Helper to create a simple viewport state - */ - function createViewport( - zoom: number = 1, - offsetX: number = 0, - offsetY: number = 0, - ): ViewportState { - return { - zoom, - offsetX, - offsetY, - displayWidth: 1000, - displayHeight: 600, - }; - } - describe('initialization and metadata', () => { it('should calculate max depth correctly for flat events', () => { const events = [createEvent(0, 100), createEvent(200, 100), createEvent(400, 100)]; @@ -101,7 +84,7 @@ describe('TimelineEventIndex', () => { const events = [createEvent(0, 100), createEvent(200, 100), createEvent(400, 100)]; const index = new TimelineEventIndex(events); - const viewport = createViewport(1, 0, 0); + const viewport = makeViewport(); // Click at screenX=50 (middle of first event) const event = index.findEventAtPosition(50, 300, viewport, 0, false); @@ -115,7 +98,7 @@ describe('TimelineEventIndex', () => { const events = [createEvent(0, 100), createEvent(200, 100), createEvent(400, 100)]; const index = new TimelineEventIndex(events); - const viewport = createViewport(2, 0, 0); // 2x zoom + const viewport = makeViewport({ zoom: 2 }); // 2x zoom // With 2x zoom, event [0-100] is rendered at [0-200] screen pixels const event = index.findEventAtPosition(100, 300, viewport, 0, false); @@ -129,7 +112,7 @@ describe('TimelineEventIndex', () => { const events = [createEvent(0, 100), createEvent(200, 100), createEvent(400, 100)]; const index = new TimelineEventIndex(events); - const viewport = createViewport(1, 100, 0); // Pan 100px right + const viewport = makeViewport({ offsetX: 100 }); // Pan 100px right // With offsetX=100, event [0-100] is rendered at [-100 to 0] // Event [200-300] is rendered at [100-200] @@ -144,7 +127,7 @@ describe('TimelineEventIndex', () => { const events = [createEvent(0, 100), createEvent(200, 100)]; const index = new TimelineEventIndex(events); - const viewport = createViewport(1, 0, 0); + const viewport = makeViewport(); // Click at screenX=150 (gap between events) const event = index.findEventAtPosition(150, 300, viewport, 0, false); @@ -156,7 +139,7 @@ describe('TimelineEventIndex', () => { const events = [createEvent(100, 100), createEvent(300, 100)]; const index = new TimelineEventIndex(events); - const viewport = createViewport(1, 0, 0); + const viewport = makeViewport(); // Click at screenX=50 (before first event) const event = index.findEventAtPosition(50, 300, viewport, 0, false); @@ -168,7 +151,7 @@ describe('TimelineEventIndex', () => { const events = [createEvent(0, 100), createEvent(200, 100)]; const index = new TimelineEventIndex(events); - const viewport = createViewport(1, 0, 0); + const viewport = makeViewport(); // Click at screenX=500 (after last event) const event = index.findEventAtPosition(500, 300, viewport, 0, false); @@ -181,7 +164,7 @@ describe('TimelineEventIndex', () => { const events = [createEvent(0, 0.01)]; // 0.01ns duration const index = new TimelineEventIndex(events); - const viewport = createViewport(1, 0, 0); // 1px per ns + const viewport = makeViewport(); // 1px per ns // Event width = 0.01px (below 0.05 threshold) const event = index.findEventAtPosition(0, 300, viewport, 0, false); @@ -194,7 +177,7 @@ describe('TimelineEventIndex', () => { const events = [createEvent(0, 0.01)]; // 0.01ns duration const index = new TimelineEventIndex(events); - const viewport = createViewport(1, 0, 0); + const viewport = makeViewport(); // With shouldIgnoreWidth=true const event = index.findEventAtPosition(0, 300, viewport, 0, true); @@ -211,7 +194,7 @@ describe('TimelineEventIndex', () => { const events = [parent]; const index = new TimelineEventIndex(events); - const viewport = createViewport(1, 0, 0); + const viewport = makeViewport(); // Click on parent at depth 0 const event = index.findEventAtPosition(10, 300, viewport, 0, false); @@ -226,7 +209,7 @@ describe('TimelineEventIndex', () => { const events = [parent]; const index = new TimelineEventIndex(events); - const viewport = createViewport(1, 0, 0); + const viewport = makeViewport(); // Click on child at depth 1 const event = index.findEventAtPosition(60, 300, viewport, 1, false); @@ -241,7 +224,7 @@ describe('TimelineEventIndex', () => { const events = [parent]; const index = new TimelineEventIndex(events); - const viewport = createViewport(1, 0, 0); + const viewport = makeViewport(); // Click on child position but search at depth 0 (parent level) const event = index.findEventAtPosition(60, 300, viewport, 0, false); @@ -257,7 +240,7 @@ describe('TimelineEventIndex', () => { const events = [level0]; const index = new TimelineEventIndex(events); - const viewport = createViewport(1, 0, 0); + const viewport = makeViewport(); // Find event at depth 2 const event = index.findEventAtPosition(65, 300, viewport, 2, false); @@ -272,7 +255,7 @@ describe('TimelineEventIndex', () => { const events = [parent]; const index = new TimelineEventIndex(events); - const viewport = createViewport(1, 0, 0); + const viewport = makeViewport(); // Search at depth 5 (doesn't exist) const event = index.findEventAtPosition(60, 300, viewport, 5, false); @@ -412,7 +395,7 @@ describe('TimelineEventIndex', () => { const events = [createEvent(100, 50), createEvent(100, 50), createEvent(100, 50)]; const index = new TimelineEventIndex(events); - const viewport = createViewport(1, 0, 0); + const viewport = makeViewport(); // Should find one of them const event = index.findEventAtPosition(120, 300, viewport, 0, false); @@ -425,7 +408,7 @@ describe('TimelineEventIndex', () => { const events = [createEvent(100, 0)]; const index = new TimelineEventIndex(events); - const viewport = createViewport(1, 0, 0); + const viewport = makeViewport(); // Zero-duration event has no width, can't be found normally const event = index.findEventAtPosition(100, 300, viewport, 0, false); diff --git a/log-viewer/src/features/timeline/__tests__/keyboard-handler.test.ts b/log-viewer/src/features/timeline/__tests__/keyboard-handler.test.ts index eb6cb2bbf..8577e97f8 100644 --- a/log-viewer/src/features/timeline/__tests__/keyboard-handler.test.ts +++ b/log-viewer/src/features/timeline/__tests__/keyboard-handler.test.ts @@ -181,34 +181,16 @@ describe('KeyboardHandler', () => { }); describe('zoom keys (W / S / + / - / =)', () => { - it('should zoom in on W key', () => { - dispatchKeyEvent('keydown', 'w'); - - expect(callbacks.onZoom).toHaveBeenCalledWith('in'); - }); - - it('should zoom out on S key', () => { - dispatchKeyEvent('keydown', 's'); - - expect(callbacks.onZoom).toHaveBeenCalledWith('out'); - }); - - it('should zoom in on + key', () => { - dispatchKeyEvent('keydown', '+'); - - expect(callbacks.onZoom).toHaveBeenCalledWith('in'); - }); - - it('should zoom in on = key', () => { - dispatchKeyEvent('keydown', '='); + it.each([ + ['in', 'w'], + ['out', 's'], + ['in', '+'], + ['in', '='], + ['out', '-'], + ] as const)('should zoom %s on the %s key', (direction, key) => { + dispatchKeyEvent('keydown', key); - expect(callbacks.onZoom).toHaveBeenCalledWith('in'); - }); - - it('should zoom out on - key', () => { - dispatchKeyEvent('keydown', '-'); - - expect(callbacks.onZoom).toHaveBeenCalledWith('out'); + expect(callbacks.onZoom).toHaveBeenCalledWith(direction); }); it('should zoom with + and - even when Shift is pressed', () => { @@ -299,37 +281,21 @@ describe('KeyboardHandler', () => { }); describe('frame navigation (onFrameNav)', () => { - it('should call onFrameNav with "up" on ArrowUp when handler returns true', () => { - (callbacks.onFrameNav as jest.Mock).mockReturnValue(true); - dispatchKeyEvent('keydown', 'ArrowUp'); - - expect(callbacks.onFrameNav).toHaveBeenCalledWith('up'); - expect(callbacks.onPan).not.toHaveBeenCalled(); - }); - - it('should call onFrameNav with "down" on ArrowDown when handler returns true', () => { - (callbacks.onFrameNav as jest.Mock).mockReturnValue(true); - dispatchKeyEvent('keydown', 'ArrowDown'); - - expect(callbacks.onFrameNav).toHaveBeenCalledWith('down'); - expect(callbacks.onPan).not.toHaveBeenCalled(); - }); - - it('should call onFrameNav with "left" on ArrowLeft when handler returns true', () => { - (callbacks.onFrameNav as jest.Mock).mockReturnValue(true); - dispatchKeyEvent('keydown', 'ArrowLeft'); - - expect(callbacks.onFrameNav).toHaveBeenCalledWith('left'); - expect(callbacks.onPan).not.toHaveBeenCalled(); - }); - - it('should call onFrameNav with "right" on ArrowRight when handler returns true', () => { - (callbacks.onFrameNav as jest.Mock).mockReturnValue(true); - dispatchKeyEvent('keydown', 'ArrowRight'); - - expect(callbacks.onFrameNav).toHaveBeenCalledWith('right'); - expect(callbacks.onPan).not.toHaveBeenCalled(); - }); + it.each([ + ['up', 'ArrowUp'], + ['down', 'ArrowDown'], + ['left', 'ArrowLeft'], + ['right', 'ArrowRight'], + ] as const)( + 'should call onFrameNav with "%s" on %s when handler returns true', + (direction, key) => { + (callbacks.onFrameNav as jest.Mock).mockReturnValue(true); + dispatchKeyEvent('keydown', key); + + expect(callbacks.onFrameNav).toHaveBeenCalledWith(direction); + expect(callbacks.onPan).not.toHaveBeenCalled(); + }, + ); it('should fall through to pan when onFrameNav returns false', () => { (callbacks.onFrameNav as jest.Mock).mockReturnValue(false); @@ -683,6 +649,7 @@ describe('KeyboardHandler', () => { dispatchKeyEvent('keydown', 'Home', { repeat: true }); dispatchKeyEvent('keydown', 'End'); dispatchKeyEvent('keydown', 'End', { repeat: true }); + // 0 and Escape are the same command, so a repeat of either is suppressed. dispatchKeyEvent('keydown', '0'); dispatchKeyEvent('keydown', 'Escape', { repeat: true }); diff --git a/log-viewer/src/features/timeline/__tests__/search-highlight.test.ts b/log-viewer/src/features/timeline/__tests__/search-highlight.test.ts index 5f6eb16d3..6deebc1bb 100644 --- a/log-viewer/src/features/timeline/__tests__/search-highlight.test.ts +++ b/log-viewer/src/features/timeline/__tests__/search-highlight.test.ts @@ -16,7 +16,8 @@ import * as PIXI from 'pixi.js'; import type { PrecomputedRect } from '../optimised/RectangleCache.js'; import { SearchHighlightRenderer } from '../optimised/search/SearchHighlightRenderer.js'; -import type { EventNode, ViewportState } from '../types/flamechart.types.js'; +import { makeViewport } from '../../../__tests__/helpers/viewport.js'; +import type { EventNode } from '../types/flamechart.types.js'; import type { SearchCursor, SearchMatch } from '../types/search.types.js'; describe('SearchHighlightRenderer', () => { @@ -144,13 +145,7 @@ describe('SearchHighlightRenderer', () => { it('should render overlay and border for current match', () => { // Given: event with normal screen width const match = createMockMatch(0, 10000, 0); - const viewport: ViewportState = { - zoom: 0.005, // screenWidth = 10000 * 0.005 = 50px - offsetX: 0, - offsetY: 0, - displayWidth: 1000, - displayHeight: 600, - }; + const viewport = makeViewport({ zoom: 0.005 }); // screenWidth = 10000 * 0.005 = 50px // When: rendering highlight const cursor = createMockCursor([match], 0); @@ -167,13 +162,7 @@ describe('SearchHighlightRenderer', () => { it('should render for small rectangles', () => { // Given: event with small screen width const match = createMockMatch(0, 100, 0); - const viewport: ViewportState = { - zoom: 0.002, // screenWidth = 100 * 0.002 = 0.2px - offsetX: 0, - offsetY: 0, - displayWidth: 1000, - displayHeight: 600, - }; + const viewport = makeViewport({ zoom: 0.002 }); // screenWidth = 100 * 0.002 = 0.2px // When: rendering const cursor = createMockCursor([match], 0); @@ -186,13 +175,7 @@ describe('SearchHighlightRenderer', () => { it('should render for large rectangles', () => { // Given: event with large screen width const match = createMockMatch(0, 40000, 0); - const viewport: ViewportState = { - zoom: 0.005, // screenWidth = 40000 * 0.005 = 200px - offsetX: 0, - offsetY: 0, - displayWidth: 1000, - displayHeight: 600, - }; + const viewport = makeViewport({ zoom: 0.005 }); // screenWidth = 40000 * 0.005 = 200px // When: rendering const cursor = createMockCursor([match], 0); @@ -206,13 +189,7 @@ describe('SearchHighlightRenderer', () => { describe('edge cases', () => { it('should handle event at timestamp zero', () => { const match = createMockMatch(0, 100, 0); - const viewport: ViewportState = { - zoom: 0.001, // screenWidth = 0.1px - offsetX: 0, - offsetY: 0, - displayWidth: 1000, - displayHeight: 600, - }; + const viewport = makeViewport({ zoom: 0.001 }); // screenWidth = 0.1px // Should not throw and should render const cursor = createMockCursor([match], 0); @@ -222,13 +199,7 @@ describe('SearchHighlightRenderer', () => { it('should handle very large duration events', () => { const match = createMockMatch(0, 1_000_000, 0); // 1ms - const viewport: ViewportState = { - zoom: 0.005, // screenWidth = 5000px - offsetX: 0, - offsetY: 0, - displayWidth: 1000, - displayHeight: 600, - }; + const viewport = makeViewport({ zoom: 0.005 }); // screenWidth = 5000px const cursor = createMockCursor([match], 0); expect(() => renderer.render(cursor, viewport)).not.toThrow(); @@ -237,13 +208,7 @@ describe('SearchHighlightRenderer', () => { it('should handle negative offset (panned left)', () => { const match = createMockMatch(500, 100, 0); - const viewport: ViewportState = { - zoom: 0.001, - offsetX: -100, // Panned left - offsetY: 0, - displayWidth: 1000, - displayHeight: 600, - }; + const viewport = makeViewport({ zoom: 0.001, offsetX: -100 }); // Panned left const cursor = createMockCursor([match], 0); expect(() => renderer.render(cursor, viewport)).not.toThrow(); @@ -252,13 +217,8 @@ describe('SearchHighlightRenderer', () => { it('should not render when match is outside viewport', () => { // Event far off-screen to the right const match = createMockMatch(1_000_000, 100, 0); - const viewport: ViewportState = { - zoom: 0.001, - offsetX: 0, - offsetY: 0, - displayWidth: 1000, - displayHeight: 600, - }; + // The cull is `timestamp >= displayWidth / zoom`, so both numbers decide this one. + const viewport = makeViewport({ zoom: 0.001, displayWidth: 1000 }); const cursor = createMockCursor([match], 0); renderer.render(cursor, viewport); @@ -269,13 +229,7 @@ describe('SearchHighlightRenderer', () => { it('should not render when currentIndex is invalid', () => { const match = createMockMatch(0, 100, 0); - const viewport: ViewportState = { - zoom: 0.005, - offsetX: 0, - offsetY: 0, - displayWidth: 1000, - displayHeight: 600, - }; + const viewport = makeViewport({ zoom: 0.005 }); // Invalid index (-1) const invalidCursor1 = createMockCursor([match], -1); @@ -291,13 +245,7 @@ describe('SearchHighlightRenderer', () => { }); it('should handle empty matches array', () => { - const viewport: ViewportState = { - zoom: 0.005, - offsetX: 0, - offsetY: 0, - displayWidth: 1000, - displayHeight: 600, - }; + const viewport = makeViewport({ zoom: 0.005 }); const emptyCursor = createMockCursor([], 0); expect(() => renderer.render(emptyCursor, viewport)).not.toThrow(); @@ -308,13 +256,7 @@ describe('SearchHighlightRenderer', () => { describe('multiple render calls', () => { it('should clear graphics before each render', () => { const match = createMockMatch(0, 100, 0); - const viewport: ViewportState = { - zoom: 0.005, - offsetX: 0, - offsetY: 0, - displayWidth: 1000, - displayHeight: 600, - }; + const viewport = makeViewport({ zoom: 0.005 }); // First render const cursor = createMockCursor([match], 0); @@ -332,13 +274,7 @@ describe('SearchHighlightRenderer', () => { }); it('should handle undefined cursor', () => { - const viewport: ViewportState = { - zoom: 0.005, - offsetX: 0, - offsetY: 0, - displayWidth: 1000, - displayHeight: 600, - }; + const viewport = makeViewport({ zoom: 0.005 }); expect(() => renderer.render(undefined, viewport)).not.toThrow(); expect(mockGraphics.rect).not.toHaveBeenCalled(); diff --git a/log-viewer/src/features/timeline/__tests__/timeline-dispose.test.ts b/log-viewer/src/features/timeline/__tests__/timeline-dispose.test.ts index 43260b4ab..070e61f8a 100644 --- a/log-viewer/src/features/timeline/__tests__/timeline-dispose.test.ts +++ b/log-viewer/src/features/timeline/__tests__/timeline-dispose.test.ts @@ -41,7 +41,6 @@ describe('legacy timeline listener lifetime', () => { dispose(); added.mockRestore(); removed.mockRestore(); - document.body.replaceChildren(); }); it('listens for the find events once the chart is built', () => { diff --git a/log-viewer/src/features/timeline/optimised/__tests__/FlameChartResize.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/FlameChartResize.test.ts index c0e77fc96..7d19f968b 100644 --- a/log-viewer/src/features/timeline/optimised/__tests__/FlameChartResize.test.ts +++ b/log-viewer/src/features/timeline/optimised/__tests__/FlameChartResize.test.ts @@ -13,9 +13,11 @@ */ import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import { internalsOf, stubChartInternals } from '../../../../__tests__/helpers/flameChart.js'; +import { makeViewport } from '../../../../__tests__/helpers/viewport.js'; import { FlameChart } from '../FlameChart.js'; -/** The private collaborators `resize` and `render` need, and nothing else. */ +/** The shared stub, plus the app and viewport handles this suite asserts on. */ function stubbedChart(displayHeight = 300): { chart: FlameChart; rendererResize: jest.Mock; @@ -25,46 +27,19 @@ function stubbedChart(displayHeight = 300): { const rendererResize = jest.fn(); const appRender = jest.fn(); - const internals = chart as unknown as Record; + const internals = stubChartInternals(chart); internals['app'] = { renderer: { resize: rendererResize }, screen: { height: 300 }, render: appRender, }; - internals['container'] = document.createElement('div'); - internals['index'] = { maxDepth: 1 }; - internals['worldContainer'] = { position: { set: jest.fn() } }; - internals['batchRenderer'] = { render: jest.fn(), clear: jest.fn() }; - internals['rectangleManager'] = { - getCulledRectangles: () => ({ visibleRects: new Map(), buckets: new Map() }), - }; internals['viewport'] = { - getState: () => ({ - zoom: 1, - offsetX: 0, - offsetY: 0, - displayWidth: 400, - displayHeight, - }), + getState: () => makeViewport({ displayWidth: 400, displayHeight }), setStateForResize: jest.fn(), }; // The geometry init applied: 364 container - 60 minimap - 4 gap = the 300 below. internals['appliedMinimapHeight'] = 60; internals['appliedOverheadHeight'] = 64; - internals['state'] = { - viewport: null, - needsRender: false, - batchColorsCache: new Map(), - renderDirty: { - background: false, - culling: false, - eventRendering: false, - highlights: false, - overlays: false, - minimap: false, - metricStrip: false, - }, - }; return { chart, rendererResize, appRender }; } @@ -124,7 +99,7 @@ describe('FlameChart.resize', () => { // so every value the guard used to compare was unchanged while the overhead moved by 19. it('draws when the overhead moved but the main timeline height did not', () => { const { chart, appRender } = stubbedChart(); - const internals = chart as unknown as Record; + const internals = internalsOf(chart); internals['metricStripOrchestrator'] = { getIsVisible: () => true, getHeight: () => 15, @@ -157,7 +132,7 @@ describe('FlameChart.resize', () => { // is still owed, or the chart stays blank with nothing left to fill it. it('books the dropped frame again when it cannot draw after all', () => { const { chart, appRender } = stubbedChart(); - const internals = chart as unknown as Record; + const internals = internalsOf(chart); // No rectangleManager, so `canRender` fails and `render` bails. internals['rectangleManager'] = null; (internals['state'] as { needsRender: boolean }).needsRender = true; @@ -172,12 +147,12 @@ describe('FlameChart.resize', () => { it('drops a render already queued, rather than painting twice', () => { const { chart, appRender } = stubbedChart(); const cancel = jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); - (chart as unknown as Record)['renderLoopId'] = 7; + internalsOf(chart)['renderLoopId'] = 7; chart.resize(500, 400); expect(cancel).toHaveBeenCalledWith(7); expect(appRender).toHaveBeenCalledTimes(1); - expect((chart as unknown as Record)['renderLoopId']).toBeNull(); + expect(internalsOf(chart)['renderLoopId']).toBeNull(); }); }); diff --git a/log-viewer/src/features/timeline/optimised/__tests__/HighlightHalo.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/HighlightHalo.test.ts index d1529f547..839fe637c 100644 --- a/log-viewer/src/features/timeline/optimised/__tests__/HighlightHalo.test.ts +++ b/log-viewer/src/features/timeline/optimised/__tests__/HighlightHalo.test.ts @@ -14,20 +14,15 @@ import { describe, expect, it } from '@jest/globals'; import { Graphics } from 'pixi.js'; -import { TIMELINE_CONSTANTS, type ViewportState } from '../../types/flamechart.types.js'; +import { makeViewport } from '../../../../__tests__/helpers/viewport.js'; +import { TIMELINE_CONSTANTS } from '../../types/flamechart.types.js'; import { createHighlightColors, MIN_HIGHLIGHT_WIDTH, renderHighlight, } from '../rendering/HighlightRenderer.js'; -const viewport: ViewportState = { - zoom: 1, - offsetX: 0, - offsetY: 0, - displayWidth: 800, - displayHeight: 300, -} as ViewportState; +const viewport = makeViewport({ displayWidth: 800, displayHeight: 300 }); /** The colours the graphics was told to stroke with, in order: the halo, then the border. */ function strokeColors(graphics: Graphics): number[] { diff --git a/log-viewer/src/features/timeline/optimised/__tests__/HoverRehit.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/HoverRehit.test.ts index 983274cac..94ecc5aea 100644 --- a/log-viewer/src/features/timeline/optimised/__tests__/HoverRehit.test.ts +++ b/log-viewer/src/features/timeline/optimised/__tests__/HoverRehit.test.ts @@ -14,49 +14,31 @@ */ import { describe, expect, it, jest } from '@jest/globals'; +import { internalsOf, stubChartInternals } from '../../../../__tests__/helpers/flameChart.js'; +import { makeViewport } from '../../../../__tests__/helpers/viewport.js'; import { FlameChart } from '../FlameChart.js'; const HIT_NODE = { id: '0-0', timestamp: 0, duration: 10, depth: 0, original: { eventIndex: 1 } }; -/** The private collaborators one `render()` needs to reach the wash, and nothing else. */ +/** The shared stub, plus the hit-test and wash handles this suite asserts on. */ function stubbedChart(): { chart: FlameChart; hoverRender: jest.Mock; hitTest: jest.Mock } { const chart = new FlameChart(); const hoverRender = jest.fn(); const hitTest = jest.fn(() => ({ eventNode: HIT_NODE, marker: null })); - const internals = chart as unknown as Record; + // Culling dirty: the re-hit this is about happens inside the cull the render then does. + const internals = stubChartInternals(chart, { culling: true }); internals['app'] = { renderer: { resize: jest.fn() }, screen: { height: 300 }, render: jest.fn(), }; - internals['container'] = document.createElement('div'); - internals['index'] = { maxDepth: 1 }; - internals['worldContainer'] = { position: { set: jest.fn() } }; - internals['batchRenderer'] = { render: jest.fn(), clear: jest.fn() }; - internals['rectangleManager'] = { - getCulledRectangles: () => ({ visibleRects: new Map(), buckets: new Map() }), - }; internals['hitDetector'] = { setVisibleRects: jest.fn(), setBuckets: jest.fn(), hitTest }; internals['hoverHighlightRenderer'] = { render: hoverRender }; internals['viewport'] = { - getState: () => ({ zoom: 1, offsetX: 0, offsetY: 0, displayWidth: 400, displayHeight: 300 }), + getState: () => makeViewport({ displayWidth: 400, displayHeight: 300 }), screenYToDepth: () => 0, }; - internals['state'] = { - viewport: null, - needsRender: false, - batchColorsCache: new Map(), - renderDirty: { - background: false, - culling: true, - eventRendering: false, - highlights: false, - overlays: false, - minimap: false, - metricStrip: false, - }, - }; return { chart, hoverRender, hitTest }; } @@ -64,7 +46,7 @@ function stubbedChart(): { chart: FlameChart; hoverRender: jest.Mock; hitTest: j describe('the hover wash after the frames move', () => { it('washes the frame now under the pointer, in the render that moved it', () => { const { chart, hoverRender } = stubbedChart(); - const internals = chart as unknown as Record; + const internals = internalsOf(chart); const tracker = internals['hoverTracker'] as { setPointer: (x: number, y: number) => void; invalidateHit: () => void; @@ -83,7 +65,7 @@ describe('the hover wash after the frames move', () => { // doing. A panel put on a frame by find or by the keyboard reads that and stays put. it('reports a re-hit as the frames moving, not as a pointer move', () => { const { chart } = stubbedChart(); - const internals = chart as unknown as Record; + const internals = internalsOf(chart); const onMouseMove = jest.fn(); internals['callbacks'] = { onMouseMove }; const tracker = internals['hoverTracker'] as { @@ -102,7 +84,7 @@ describe('the hover wash after the frames move', () => { // and a tooltip churning through frames as they slide past, are both noise. it('washes nothing while a drag owns the pointer, and asks once it ends', () => { const { chart, hitTest, hoverRender } = stubbedChart(); - const internals = chart as unknown as Record; + const internals = internalsOf(chart); let dragging = true; internals['interactionHandler'] = { isPointerDragging: () => dragging, @@ -137,7 +119,7 @@ describe('the hover wash after the frames move', () => { // Culling is what moves the frames, so a render that reuses it leaves the answer standing. it('does not ask again on a render that reuses the culled frames', () => { const { chart, hitTest } = stubbedChart(); - const internals = chart as unknown as Record; + const internals = internalsOf(chart); (internals['hoverTracker'] as { setPointer: (x: number, y: number) => void }).setPointer( 40, 10, diff --git a/log-viewer/src/features/timeline/optimised/__tests__/HoverWash.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/HoverWash.test.ts index 9296dd928..8eeefe24b 100644 --- a/log-viewer/src/features/timeline/optimised/__tests__/HoverWash.test.ts +++ b/log-viewer/src/features/timeline/optimised/__tests__/HoverWash.test.ts @@ -13,20 +13,15 @@ import { describe, expect, it } from '@jest/globals'; import { Graphics } from 'pixi.js'; -import { TIMELINE_CONSTANTS, type ViewportState } from '../../types/flamechart.types.js'; +import { makeViewport } from '../../../../__tests__/helpers/viewport.js'; +import { TIMELINE_CONSTANTS } from '../../types/flamechart.types.js'; import { createHighlightColors, renderHighlight, renderWash, } from '../rendering/HighlightRenderer.js'; -const viewport: ViewportState = { - zoom: 1, - offsetX: 0, - offsetY: 0, - displayWidth: 800, - displayHeight: 300, -} as ViewportState; +const viewport = makeViewport({ displayWidth: 800, displayHeight: 300 }); /** The actions the graphics recorded, in order. */ function actions(graphics: Graphics): string[] { diff --git a/log-viewer/src/features/timeline/optimised/__tests__/RectangleCache.bucket.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/RectangleCache.bucket.test.ts index 3dc578acd..a02fcf029 100644 --- a/log-viewer/src/features/timeline/optimised/__tests__/RectangleCache.bucket.test.ts +++ b/log-viewer/src/features/timeline/optimised/__tests__/RectangleCache.bucket.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from '@jest/globals'; import type { LogCategory, LogEvent } from 'apex-log-parser'; +import { makeViewport } from '../../../../__tests__/helpers/viewport.js'; import type { ViewportState } from '../../types/flamechart.types.js'; import { TIMELINE_CONSTANTS } from '../../types/flamechart.types.js'; import { legacyCullRectangles } from '../LegacyViewportCuller.js'; @@ -38,21 +39,9 @@ function createEvent( } as unknown as LogEvent; } -// Helper to create viewport state -function createViewport( - zoom = 1, - offsetX = 0, - offsetY = 0, - displayWidth = 1000, - displayHeight = 500, -): ViewportState { - return { - zoom, - offsetX, - offsetY, - displayWidth, - displayHeight, - }; +/** The 1000x500 canvas both culling oracles measure against. */ +function createViewport(over: Partial = {}): ViewportState { + return makeViewport({ displayHeight: 500, ...over }); } // Helper to cull rectangles using the legacy O(n) algorithm @@ -99,7 +88,7 @@ describe('Legacy bucket aggregation', () => { it('should return events > 2px in visibleRects', () => { // Event with duration 3ns at zoom=1 gives 3px width (> MIN_RECT_SIZE) const events = [createEvent(0, 3, 'Apex')]; - const viewport = createViewport(1, 0, 0); + const viewport = createViewport(); const result = cullRectanglesLegacy(events, categories, viewport); expect(result.visibleRects.get('Apex')).toHaveLength(1); @@ -111,7 +100,7 @@ describe('Legacy bucket aggregation', () => { it('should aggregate events <= 2px into buckets', () => { // Event with duration 1ns at zoom=1 gives 1px width (<= MIN_RECT_SIZE) const events = [createEvent(0, 1, 'Apex')]; - const viewport = createViewport(1, 0, 0); + const viewport = createViewport(); const result = cullRectanglesLegacy(events, categories, viewport); // No visible rects (event is too small), so category has no entry @@ -128,7 +117,7 @@ describe('Legacy bucket aggregation', () => { createEvent(20, 3, 'SOQL'), // 3px at zoom=1 - visible createEvent(30, 0.5, 'SOQL'), // 0.5px at zoom=1 - bucketed ]; - const viewport = createViewport(1, 0, 0); + const viewport = createViewport(); const result = cullRectanglesLegacy(events, categories, viewport); expect(result.visibleRects.get('Apex')).toHaveLength(1); @@ -142,7 +131,7 @@ describe('Legacy bucket aggregation', () => { it('should create time-aligned bucket boundaries', () => { // At zoom=1, bucket width is 2ns (2px / 1) const events = [createEvent(5, 1, 'Apex')]; // Event at timestamp 5 - const viewport = createViewport(1, 0, 0); + const viewport = createViewport(); const result = cullRectanglesLegacy(events, categories, viewport); const allBuckets = getAllBuckets(result.buckets); @@ -158,7 +147,7 @@ describe('Legacy bucket aggregation', () => { it('should group events in same time bucket together', () => { // Two events at timestamps 4 and 5 should be in same bucket (index 2, range [4,6)) const events = [createEvent(4, 1, 'Apex'), createEvent(5, 1, 'Apex')]; - const viewport = createViewport(1, 0, 0); + const viewport = createViewport(); const result = cullRectanglesLegacy(events, categories, viewport); const allBuckets = getAllBuckets(result.buckets); @@ -173,7 +162,7 @@ describe('Legacy bucket aggregation', () => { createEvent(0, 1, 'Apex'), // bucket index 0 createEvent(10, 1, 'Apex'), // bucket index 5 ]; - const viewport = createViewport(1, 0, 0); + const viewport = createViewport(); const result = cullRectanglesLegacy(events, categories, viewport); expect(countBuckets(result.buckets)).toBe(2); @@ -186,7 +175,7 @@ describe('Legacy bucket aggregation', () => { const child = createEvent(0, 1, 'SOQL'); const parent = createEvent(0, 1, 'Apex', [child]); const events = [parent]; - const viewport = createViewport(1, 0, 0); + const viewport = createViewport(); const result = cullRectanglesLegacy(events, categories, viewport); // Should have 2 buckets (one per depth) @@ -199,7 +188,7 @@ describe('Legacy bucket aggregation', () => { it('should set correct Y position based on depth', () => { const events = [createEvent(0, 1, 'Apex')]; - const viewport = createViewport(1, 0, 0); + const viewport = createViewport(); const result = cullRectanglesLegacy(events, categories, viewport); const allBuckets = getAllBuckets(result.buckets); @@ -211,7 +200,7 @@ describe('Legacy bucket aggregation', () => { const events2 = [parent]; // At zoom=0.5, parent (2ns) becomes 1px (bucketed), child (1ns) becomes 0.5px (bucketed) - const viewport2 = createViewport(0.5, 0, 0); + const viewport2 = createViewport({ zoom: 0.5 }); const result2 = cullRectanglesLegacy(events2, categories, viewport2); const allBuckets2 = getAllBuckets(result2.buckets); @@ -227,7 +216,7 @@ describe('Legacy bucket aggregation', () => { createEvent(1, 1, 'SOQL'), createEvent(0.5, 1, 'Apex'), ]; - const viewport = createViewport(1, 0, 0); + const viewport = createViewport(); const result = cullRectanglesLegacy(events, categories, viewport); // All 3 events at zoom=1 with duration 1ns are < 2px, so all bucketed @@ -242,7 +231,7 @@ describe('Legacy bucket aggregation', () => { it('should track total duration per category', () => { const events = [createEvent(0, 1, 'Apex'), createEvent(0.5, 0.5, 'Apex')]; - const viewport = createViewport(1, 0, 0); + const viewport = createViewport(); const result = cullRectanglesLegacy(events, categories, viewport); const allBuckets = getAllBuckets(result.buckets); @@ -254,7 +243,7 @@ describe('Legacy bucket aggregation', () => { describe('bucket color resolution', () => { it('should prioritize DML over Method in mixed bucket', () => { const events = [createEvent(0, 1, 'Apex'), createEvent(0.5, 1, 'DML')]; - const viewport = createViewport(1, 0, 0); + const viewport = createViewport(); const result = cullRectanglesLegacy(events, categories, viewport); // Get bucket from DML category (dominant) @@ -267,7 +256,7 @@ describe('Legacy bucket aggregation', () => { it('should prioritize SOQL over Method in mixed bucket', () => { const events = [createEvent(0, 1, 'Apex'), createEvent(0.5, 1, 'SOQL')]; - const viewport = createViewport(1, 0, 0); + const viewport = createViewport(); const result = cullRectanglesLegacy(events, categories, viewport); // Get bucket from SOQL category (dominant) @@ -279,7 +268,7 @@ describe('Legacy bucket aggregation', () => { describe('bucket color blending', () => { it('should have a valid color for single event', () => { const events = [createEvent(0, 1, 'Apex')]; - const viewport = createViewport(1, 0, 0); + const viewport = createViewport(); const result = cullRectanglesLegacy(events, categories, viewport); // Color should be a valid numeric color value (pre-blended opaque) @@ -294,7 +283,7 @@ describe('Legacy bucket aggregation', () => { // Create a bucket with a single event const singleEvent = [createEvent(0, 1, 'Apex')]; - const singleViewport = createViewport(1, 0, 0); + const singleViewport = createViewport(); const singleResult = cullRectanglesLegacy(singleEvent, categories, singleViewport); const singleBuckets = getAllBuckets(singleResult.buckets); const singleBucketColor = singleBuckets[0]!.color; @@ -304,7 +293,7 @@ describe('Legacy bucket aggregation', () => { for (let i = 0; i < 50; i++) { manyEvents.push(createEvent(i * 0.03, 0.01, 'Apex')); // All in bucket index 0 } - const manyViewport = createViewport(1, 0, 0); + const manyViewport = createViewport(); const manyResult = cullRectanglesLegacy(manyEvents, categories, manyViewport); const manyBuckets = getAllBuckets(manyResult.buckets); const manyBucketColor = manyBuckets[0]!.color; @@ -319,7 +308,7 @@ describe('Legacy bucket aggregation', () => { const event1 = createEvent(0, 1, 'Apex'); const event2 = createEvent(0.5, 1, 'Apex'); const events = [event1, event2]; - const viewport = createViewport(1, 0, 0); + const viewport = createViewport(); const result = cullRectanglesLegacy(events, categories, viewport); const allBuckets = getAllBuckets(result.buckets); @@ -336,7 +325,7 @@ describe('Legacy bucket aggregation', () => { createEvent(10, 1, 'Apex'), // bucketed createEvent(20, 1, 'SOQL'), // bucketed ]; - const viewport = createViewport(1, 0, 0); + const viewport = createViewport(); const result = cullRectanglesLegacy(events, categories, viewport); expect(result.stats.visibleCount).toBe(1); @@ -350,7 +339,7 @@ describe('Legacy bucket aggregation', () => { for (let i = 0; i < 5; i++) { events.push(createEvent(i * 0.3, 0.1, 'Apex')); } - const viewport = createViewport(1, 0, 0); + const viewport = createViewport(); const result = cullRectanglesLegacy(events, categories, viewport); expect(result.stats.maxEventsPerBucket).toBe(5); diff --git a/log-viewer/src/features/timeline/optimised/__tests__/TemporalSegmentTree.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/TemporalSegmentTree.test.ts index 592df6087..a811d0649 100644 --- a/log-viewer/src/features/timeline/optimised/__tests__/TemporalSegmentTree.test.ts +++ b/log-viewer/src/features/timeline/optimised/__tests__/TemporalSegmentTree.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from '@jest/globals'; import type { LogCategory, LogEvent } from 'apex-log-parser'; +import { makeViewport } from '../../../../__tests__/helpers/viewport.js'; import type { PixelBucket, ViewportState } from '../../types/flamechart.types.js'; import { TIMELINE_CONSTANTS } from '../../types/flamechart.types.js'; import type { BatchColorInfo } from '../BucketColorResolver.js'; @@ -39,21 +40,9 @@ function createEvent( } as unknown as LogEvent; } -// Helper to create viewport state -function createViewport( - zoom = 1, - offsetX = 0, - offsetY = 0, - displayWidth = 1000, - displayHeight = 500, -): ViewportState { - return { - zoom, - offsetX, - offsetY, - displayWidth, - displayHeight, - }; +/** The 1000x500 canvas both culling oracles measure against. */ +function createViewport(over: Partial = {}): ViewportState { + return makeViewport({ displayHeight: 500, ...over }); } // Helper to flatten buckets Map into array for testing @@ -124,7 +113,7 @@ describe('TemporalSegmentTree', () => { const manager = new RectangleCache(events, categories); const tree = new TemporalSegmentTree(manager.getRectsByCategory()); - const viewport = createViewport(1, 0, 0); + const viewport = createViewport(); const result = tree.query(viewport, EMPTY_BATCH_COLORS); expect(result.visibleRects.get('Apex')).toHaveLength(1); @@ -138,7 +127,7 @@ describe('TemporalSegmentTree', () => { const manager = new RectangleCache(events, categories); const tree = new TemporalSegmentTree(manager.getRectsByCategory()); - const viewport = createViewport(1, 0, 0); + const viewport = createViewport(); const result = tree.query(viewport, EMPTY_BATCH_COLORS); // Pre-initialized map has empty arrays for known categories @@ -157,7 +146,7 @@ describe('TemporalSegmentTree', () => { const manager = new RectangleCache(events, categories); const tree = new TemporalSegmentTree(manager.getRectsByCategory()); - const viewport = createViewport(0.1, 0, 0); + const viewport = createViewport({ zoom: 0.1 }); const result = tree.query(viewport, EMPTY_BATCH_COLORS); // All events should be bucketed at this zoom level @@ -177,11 +166,11 @@ describe('TemporalSegmentTree', () => { const tree = new TemporalSegmentTree(manager.getRectsByCategory()); // Zoomed out: all events are small - const zoomedOut = createViewport(0.1, 0, 0, 1000); + const zoomedOut = createViewport({ zoom: 0.1 }); const resultOut = tree.query(zoomedOut, EMPTY_BATCH_COLORS); // Zoomed in: all events are visible - const zoomedIn = createViewport(2, 0, 0, 1000); + const zoomedIn = createViewport({ zoom: 2 }); const resultIn = tree.query(zoomedIn, EMPTY_BATCH_COLORS); // More visible rects when zoomed in @@ -197,9 +186,9 @@ describe('TemporalSegmentTree', () => { const manager = new RectangleCache(events, categories); const tree = new TemporalSegmentTree(manager.getRectsByCategory()); - const zoomed1 = createViewport(0.1, 0, 0, 1000); - const zoomed2 = createViewport(1, 0, 0, 1000); - const zoomed3 = createViewport(10, 0, 0, 1000); + const zoomed1 = createViewport({ zoom: 0.1 }); + const zoomed2 = createViewport(); + const zoomed3 = createViewport({ zoom: 10 }); const result1 = tree.query(zoomed1, EMPTY_BATCH_COLORS); const result2 = tree.query(zoomed2, EMPTY_BATCH_COLORS); @@ -227,7 +216,7 @@ describe('TemporalSegmentTree', () => { const tree = new TemporalSegmentTree(manager.getRectsByCategory()); // Viewport only shows time 50-150 (should only include second event) - const viewport = createViewport(1, 50, 0, 100); + const viewport = createViewport({ offsetX: 50, displayWidth: 100 }); const result = tree.query(viewport, EMPTY_BATCH_COLORS); // Only the middle event should be visible @@ -246,23 +235,15 @@ describe('TemporalSegmentTree', () => { // offsetY = 0, height = 2 rows (30px) // worldYBottom = 0, worldYTop = 30 // depthStart = 0, depthEnd = 2 - const viewportSmall = createViewport( - 1, - 0, - 0, - 1000, - TIMELINE_CONSTANTS.EVENT_HEIGHT * 2, // shows depths 0-1 - ); + const viewportSmall = createViewport({ + displayHeight: TIMELINE_CONSTANTS.EVENT_HEIGHT * 2, // shows depths 0-1 + }); const resultSmall = tree.query(viewportSmall, EMPTY_BATCH_COLORS); // Create a larger viewport that shows all 3 depths - const viewportLarge = createViewport( - 1, - 0, - 0, - 1000, - TIMELINE_CONSTANTS.EVENT_HEIGHT * 4, // shows depths 0-3 - ); + const viewportLarge = createViewport({ + displayHeight: TIMELINE_CONSTANTS.EVENT_HEIGHT * 4, // shows depths 0-3 + }); const resultLarge = tree.query(viewportLarge, EMPTY_BATCH_COLORS); // Smaller viewport should have fewer or equal events @@ -280,7 +261,7 @@ describe('TemporalSegmentTree', () => { const manager = new RectangleCache(events, categories); const tree = new TemporalSegmentTree(manager.getRectsByCategory()); - const viewport = createViewport(1, 0, 0); + const viewport = createViewport(); const result = tree.query(viewport, EMPTY_BATCH_COLORS); const allBuckets = getAllBuckets(result.buckets); @@ -299,7 +280,7 @@ describe('TemporalSegmentTree', () => { const manager = new RectangleCache(events, categories); const tree = new TemporalSegmentTree(manager.getRectsByCategory()); - const viewport = createViewport(0.5, 0, 0); // threshold = 4ns + const viewport = createViewport({ zoom: 0.5 }); // threshold = 4ns const result = tree.query(viewport, EMPTY_BATCH_COLORS); // All events should be in buckets with correct count @@ -311,7 +292,7 @@ describe('TemporalSegmentTree', () => { const manager = new RectangleCache(events, categories); const tree = new TemporalSegmentTree(manager.getRectsByCategory()); - const viewport = createViewport(0.1, 0, 0); // threshold = 20ns + const viewport = createViewport({ zoom: 0.1 }); // threshold = 20ns const result = tree.query(viewport, EMPTY_BATCH_COLORS); // Bucket should have stats for both categories @@ -334,7 +315,7 @@ describe('TemporalSegmentTree', () => { // Both implementations now use segment tree (legacy is in LegacyViewportCuller) // This test verifies the manager produces consistent results const manager = new RectangleCache(events, categories); - const viewport = createViewport(1, 0, 0); + const viewport = createViewport(); const result = manager.getCulledRectangles(viewport, EMPTY_BATCH_COLORS); // For comparison with legacy, use the legacy culler directly @@ -369,7 +350,7 @@ describe('TemporalSegmentTree', () => { // Query time range that only intersects with the long event (not the short one) // Using viewport that shows time [70, 90] // At zoom=1, offset=70, width=20: timeStart=70, timeEnd=90 - const viewport = createViewport(1, 70, 0, 20, 500); + const viewport = createViewport({ offsetX: 70, displayWidth: 20 }); const result = tree.query(viewport, EMPTY_BATCH_COLORS); // The long event (Method) should be visible because it spans [0, 100] @@ -391,7 +372,7 @@ describe('TemporalSegmentTree', () => { const tree = new TemporalSegmentTree(manager.getRectsByCategory()); // Query time range [80, 120] - only overlaps with the SOQL event (timeEnd=110) - const viewport = createViewport(1, 80, 0, 40, 500); + const viewport = createViewport({ offsetX: 80, displayWidth: 40 }); const result = tree.query(viewport, EMPTY_BATCH_COLORS); const totalEvents = result.stats.visibleCount + result.stats.bucketedEventCount; @@ -423,7 +404,7 @@ describe('TemporalSegmentTree', () => { const manager = new RectangleCache(events, categories); const tree = new TemporalSegmentTree(manager.getRectsByCategory()); - const viewport = createViewport(0.1, 0, 0); // threshold = 20ns, event = 1ns + const viewport = createViewport({ zoom: 0.1 }); // threshold = 20ns, event = 1ns const result = tree.query(viewport, EMPTY_BATCH_COLORS); const allBuckets = getAllBuckets(result.buckets); @@ -438,7 +419,7 @@ describe('TemporalSegmentTree', () => { const manager = new RectangleCache(events, categories); const tree = new TemporalSegmentTree(manager.getRectsByCategory()); - const viewport = createViewport(1, 0, 0); // threshold = 2ns, event = 1ns + const viewport = createViewport(); // threshold = 2ns, event = 1ns const result = tree.query(viewport, EMPTY_BATCH_COLORS); const allBuckets = getAllBuckets(result.buckets); @@ -460,7 +441,7 @@ describe('TemporalSegmentTree', () => { const tree = new TemporalSegmentTree(manager.getRectsByCategory()); // Zoom out so all events aggregate into one bucket - const viewport = createViewport(0.01, 0, 0, 1000); + const viewport = createViewport({ zoom: 0.01 }); const result = tree.query(viewport, EMPTY_BATCH_COLORS); // Bucket should be categorized as DML (priority 0 beats priority 1) diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.test.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.test.ts index 6dd09446a..5a77fb4dd 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.test.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.test.ts @@ -12,6 +12,7 @@ import type { MetricStripClassifiedMetric, MetricStripDataPoint, } from '../../types/flamechart.types.js'; +import { waitForNextFrame } from '../../../../core/utility/FrameBudget.js'; import { PERCENT_COLORS } from '../rendering/tooltip-utils.js'; import { MetricStripTooltipRenderer } from './MetricStripTooltipRenderer.js'; @@ -63,9 +64,7 @@ describe('MetricStripTooltipRenderer', () => { } /** Placement is batched into a frame, so it has to be let through. */ - function flushFrame(): Promise { - return new Promise((resolve) => requestAnimationFrame(() => resolve())); - } + const flushFrame = waitForNextFrame; /** One always-show metric, enough to get a row on the panel. */ const oneMetric = [metric('cpuTime', 'CPU Time', 0.9)]; diff --git a/package.json b/package.json index d7d9f072c..23fd6008c 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "typecheck": "tsc -b && tsc -p scripts/tsconfig.json", "typecheck:tsc6": "tsc6 -b", "measure": "rolldown -c scripts/measure/rolldown.config.ts && node --expose-gc --max-old-space-size=12288 scripts/measure/out/measure.mjs", - "lint": "concurrently -r -g 'eslint . --cache --cache-location node_modules/.cache/eslint/' 'prettier --cache **/*.{ts,css,md,mdx,scss} --check --experimental-cli' 'pnpm run typecheck'", + "lint": "concurrently -r -g 'eslint . --cache --cache-location node_modules/.cache/eslint/' 'prettier --cache \"**/*.{ts,css,md,mdx,scss}\" --check --experimental-cli' 'pnpm run typecheck'", "test": "jest", "test:ci": "jest --runInBand", "test:e2e:web": "pnpm build && playwright test --config=lana/test/playwright/playwright.config.web.ts",