Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
.DS_Store

node_modules
.pnpm-store/
dist/
build/
out/
Expand Down
4 changes: 4 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
6 changes: 6 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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$': '<rootDir>/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$)':
'<rootDir>/src/__tests__/mocks/vscodeElementStub.ts',
},
transformIgnorePatterns: [
// allow transformation of pixi.js and its dependencies
Expand Down
6 changes: 1 addition & 5 deletions lana/src/__tests__/Main.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();

Expand Down
30 changes: 29 additions & 1 deletion lana/src/__tests__/helpers/test-builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -200,3 +206,25 @@ export function createMockContext(overrides: Partial<MockContext> = {}): 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<unknown> {
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<unknown>;
}
181 changes: 41 additions & 140 deletions lana/src/__tests__/log-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});

Expand Down
7 changes: 4 additions & 3 deletions lana/src/cache/__tests__/LogEventCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Uri, workspace } from 'vscode';

import {
createMockApexLog,
asContext,
createMockContext,
createMockDisplay,
createMockLogEvent,
Expand Down Expand Up @@ -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);
Expand All @@ -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!({
Expand Down Expand Up @@ -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.
Expand Down
23 changes: 7 additions & 16 deletions lana/src/commands/__tests__/Command.test.ts
Original file line number Diff line number Diff line change
@@ -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'),
);
Expand All @@ -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')),
);
Expand All @@ -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');
Expand All @@ -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 [
Expand Down
Loading
Loading