From d27e00820b748eba227e5f86915d33e66f6e05b7 Mon Sep 17 00:00:00 2001 From: peternhale Date: Thu, 13 Aug 2026 09:31:33 -0600 Subject: [PATCH 1/3] ci: clarify manual publish dry run --- .github/workflows/manual-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml index acea36f3..61c8015c 100644 --- a/.github/workflows/manual-publish.yml +++ b/.github/workflows/manual-publish.yml @@ -49,7 +49,7 @@ on: default: '' type: string dry-run: - description: 'Build and validate artifacts without publishing, tagging, or committing' + description: 'Validate selected release artifacts without publishing, tagging, or committing' required: true default: true type: boolean From d677cf7ce2bcce9d8d644a71e6086ba9e0c6b3fd Mon Sep 17 00:00:00 2001 From: peternhale Date: Thu, 13 Aug 2026 10:03:36 -0600 Subject: [PATCH 2/3] fix: configure manual publish E2E checks --- .github/workflows/manual-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml index 61c8015c..c61cd170 100644 --- a/.github/workflows/manual-publish.yml +++ b/.github/workflows/manual-publish.yml @@ -76,7 +76,7 @@ jobs: exclude-web-vsix: 'true' extensions-root: . vsix-artifact-name: vsix-packages - required-ci-checks: E2E + required-ci-checks: E2E / Web E2E, E2E / Desktop E2E (macos-latest), E2E / Desktop E2E (windows-latest) node-version: '24' package-manager: pnpm package-manager-version: '10' From d2ee3159ebd056991b3276114e08f270bd156078 Mon Sep 17 00:00:00 2001 From: peternhale Date: Mon, 17 Aug 2026 10:49:02 -0600 Subject: [PATCH 3/3] fix: preserve web log URIs and report errors --- lana/src/AppSettings.ts | 2 +- lana/src/commands/LogView.ts | 4 +- .../__tests__/RetrieveLogFile.test.ts | 43 ++++++++++++++++++- .../__tests__/SwitchTimelineTheme.test.ts | 2 +- lana/src/display/Display.ts | 1 + lana/src/display/__tests__/Display.test.ts | 20 +++++++++ lana/src/log-features/RawLogNavigation.ts | 14 +++++- .../__tests__/salesforceServices.test.ts | 40 +++++++++++++++++ lana/src/services/salesforceServices.ts | 6 +-- 9 files changed, 122 insertions(+), 10 deletions(-) create mode 100644 lana/src/display/__tests__/Display.test.ts create mode 100644 lana/src/services/__tests__/salesforceServices.test.ts diff --git a/lana/src/AppSettings.ts b/lana/src/AppSettings.ts index 69d32d42..b17c09f5 100644 --- a/lana/src/AppSettings.ts +++ b/lana/src/AppSettings.ts @@ -2,4 +2,4 @@ * Copyright (c) 2022 Certinia Inc. All rights reserved. */ -export const appName = 'Lana'; +export const appName = 'Salesforce Apex Log Analyzer'; diff --git a/lana/src/commands/LogView.ts b/lana/src/commands/LogView.ts index 6ad22320..e7767ded 100644 --- a/lana/src/commands/LogView.ts +++ b/lana/src/commands/LogView.ts @@ -186,7 +186,7 @@ export class LogView { if (destinationFile) { writeFile(destinationFile, fileContent).then(undefined, (error) => { const msg = error instanceof Error ? error.message : String(error); - vscWindow.showErrorMessage(`Unable to save file: ${msg}`); + context.display.showErrorMessage(`Unable to save file: ${msg}`); }); } } @@ -202,7 +202,7 @@ export class LogView { case 'goToLogLine': { if (isTimestampPayload(payload) && logUri) { - RawLogNavigation.goToLineByTimestamp(logUri, payload.timestamp); + RawLogNavigation.goToLineByTimestamp(logUri, payload.timestamp, context.display); } break; } diff --git a/lana/src/commands/__tests__/RetrieveLogFile.test.ts b/lana/src/commands/__tests__/RetrieveLogFile.test.ts index 19268d2b..eebeae4b 100644 --- a/lana/src/commands/__tests__/RetrieveLogFile.test.ts +++ b/lana/src/commands/__tests__/RetrieveLogFile.test.ts @@ -136,7 +136,7 @@ describe('RetrieveLogFile', () => { RetrieveLogFile.apply(mockContext as unknown as import('../../Context.js').Context); expect(mockContext.display.output).toHaveBeenCalledWith( - "Registered command 'Lana: Retrieve Log'", + "Registered command 'Salesforce Apex Log Analyzer: Retrieve Log'", ); }); }); @@ -735,6 +735,47 @@ describe('RetrieveLogFile', () => { }); describe('writeLogFile', () => { + it('preserves the memfs URI through the deferred log write', async () => { + const workspaceUri = Uri.parse('memfs:/test/workspace'); + const testWs = new VSWorkspace({ + uri: workspaceUri, + name: 'test-workspace', + index: 0, + }); + mockPickOrReturn.mockResolvedValue(testWs); + mockListLogs.mockResolvedValue([ + { + Id: 'download-me', + LogUser: { Name: 'User' }, + Operation: 'Op', + LogLength: 1024, + DurationMilliseconds: 100, + StartTime: '2024-01-01T00:00:00.000Z', + Status: 'Success', + }, + ]); + mockQuickPickPick.mockResolvedValue([{ logId: 'download-me' }]); + mockFileOrFolderExists.mockResolvedValue(false); + mockGetLogBody.mockResolvedValue('fetched body'); + mockWriteFile.mockResolvedValue(undefined); + mockCreateView.mockResolvedValue({ panel: 'mock' }); + + const mockContext = createMockContext(); + RetrieveLogFile.apply(mockContext as unknown as import('../../Context.js').Context); + + const lastCall = mockRegisterCommand.mock.calls[mockRegisterCommand.mock.calls.length - 1]; + await lastCall[1](); + + const [, writeLogFile, logUri] = mockCreateView.mock.calls[0]; + await writeLogFile; + + expect(logUri).toEqual( + Uri.parse('memfs:/test/workspace/.sfdx/tools/debug/logs/download-me.log'), + ); + expect(mockFileOrFolderExists).toHaveBeenCalledWith(logUri); + expect(mockWriteFile).toHaveBeenCalledWith(logUri, 'fetched body'); + }); + it('should call getLogBody + writeFile when file does not exist', async () => { const testWs = new VSWorkspace({ uri: Uri.parse('file:///test/ws'), diff --git a/lana/src/commands/__tests__/SwitchTimelineTheme.test.ts b/lana/src/commands/__tests__/SwitchTimelineTheme.test.ts index 35b3ce3c..95507e4d 100644 --- a/lana/src/commands/__tests__/SwitchTimelineTheme.test.ts +++ b/lana/src/commands/__tests__/SwitchTimelineTheme.test.ts @@ -414,7 +414,7 @@ describe('SwitchTimelineTheme', () => { SwitchTimelineTheme.apply(mockContext as unknown as import('../../Context.js').Context); expect(mockContext.display.output).toHaveBeenCalledWith( - "Registered command 'Lana: Timeline Theme'", + "Registered command 'Salesforce Apex Log Analyzer: Timeline Theme'", ); }); }); diff --git a/lana/src/display/Display.ts b/lana/src/display/Display.ts index 2c372d2c..43b23302 100644 --- a/lana/src/display/Display.ts +++ b/lana/src/display/Display.ts @@ -20,6 +20,7 @@ export class Display { } showErrorMessage(s: string, options: MessageOptions = {}): void { + this.output(s, true); window.showErrorMessage(s, options); } diff --git a/lana/src/display/__tests__/Display.test.ts b/lana/src/display/__tests__/Display.test.ts new file mode 100644 index 00000000..0254905a --- /dev/null +++ b/lana/src/display/__tests__/Display.test.ts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; +import { window } from 'vscode'; + +import { Display } from '../Display.js'; + +describe('Display', () => { + it('writes errors to the output channel before displaying them', () => { + const display = new Display(); + const outputChannel = (window.createOutputChannel as jest.Mock).mock.results.at(-1)?.value; + + display.showErrorMessage('Unable to read log'); + + expect(outputChannel.appendLine).toHaveBeenCalledWith('Unable to read log'); + expect(outputChannel.show).toHaveBeenCalledWith(true); + expect(window.showErrorMessage).toHaveBeenCalledWith('Unable to read log', {}); + }); +}); diff --git a/lana/src/log-features/RawLogNavigation.ts b/lana/src/log-features/RawLogNavigation.ts index 7fc74eb9..d33d1fc9 100644 --- a/lana/src/log-features/RawLogNavigation.ts +++ b/lana/src/log-features/RawLogNavigation.ts @@ -4,6 +4,7 @@ import { Selection, commands, window, type Uri } from 'vscode'; import { readFile } from '../services/salesforceServices.js'; +import type { Display } from '../display/Display.js'; /** * Handles navigation within raw Apex log files. @@ -17,7 +18,11 @@ export class RawLogNavigation { * @param logUri - URI of the log file (works on desktop file:// and web vscode-vfs://) * @param timestamp - Nanosecond timestamp to find (from log event) */ - public static async goToLineByTimestamp(logUri: Uri, timestamp: number): Promise { + public static async goToLineByTimestamp( + logUri: Uri, + timestamp: number, + display?: Display, + ): Promise { try { // Read file (no normalization - avoids doubling memory for large files) const text = await readFile(logUri); @@ -51,7 +56,12 @@ export class RawLogNavigation { }); } catch (error) { const msg = error instanceof Error ? error.message : String(error); - window.showErrorMessage(`Unable to navigate to log line: ${msg}`); + const errorMessage = `Unable to navigate to log line: ${msg}`; + if (display) { + display.showErrorMessage(errorMessage); + } else { + window.showErrorMessage(errorMessage); + } } } } diff --git a/lana/src/services/__tests__/salesforceServices.test.ts b/lana/src/services/__tests__/salesforceServices.test.ts new file mode 100644 index 00000000..377c61a1 --- /dev/null +++ b/lana/src/services/__tests__/salesforceServices.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { beforeEach, describe, expect, it } from '@jest/globals'; +import { Uri } from 'vscode'; + +const mockFsService = { + readFile: jest.fn(), + safeWriteFile: jest.fn(), + fileOrFolderExists: jest.fn(), +}; +const mockRunPromise = jest.fn((effect: unknown) => Promise.resolve(effect)); + +jest.mock('../servicesRuntime.js', () => ({ + getServicesApi: () => ({ services: { FsService: mockFsService } }), + getRuntime: () => ({ runPromise: mockRunPromise }), +})); + +import { fileOrFolderExists, readFile, writeFile } from '../salesforceServices.js'; + +describe('salesforceServices filesystem adapters', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('passes a virtual filesystem URI directly to FsService', async () => { + const uri = Uri.parse('memfs:/test/workspace/log.log'); + mockFsService.readFile.mockReturnValueOnce('log content'); + mockFsService.safeWriteFile.mockReturnValueOnce(undefined); + mockFsService.fileOrFolderExists.mockReturnValueOnce(true); + + await expect(readFile(uri)).resolves.toBe('log content'); + await expect(writeFile(uri, 'updated log')).resolves.toBeUndefined(); + await expect(fileOrFolderExists(uri)).resolves.toBe(true); + + expect(mockFsService.readFile).toHaveBeenCalledWith(uri); + expect(mockFsService.safeWriteFile).toHaveBeenCalledWith(uri, 'updated log'); + expect(mockFsService.fileOrFolderExists).toHaveBeenCalledWith(uri); + }); +}); diff --git a/lana/src/services/salesforceServices.ts b/lana/src/services/salesforceServices.ts index 8a516100..852ead2e 100644 --- a/lana/src/services/salesforceServices.ts +++ b/lana/src/services/salesforceServices.ts @@ -43,19 +43,19 @@ export function getLogBody(logId: string): Promise { /** Read a file as UTF-8 text (web-safe via vscode.workspace.fs). */ export function readFile(uri: Uri | string): Promise { const { FsService } = getServicesApi().services; - return getRuntime().runPromise(FsService.readFile(uri.toString())); + return getRuntime().runPromise(FsService.readFile(uri)); } /** Write UTF-8 text to a file, creating parent directories if needed. */ export function writeFile(uri: Uri | string, content: string): Promise { const { FsService } = getServicesApi().services; - return getRuntime().runPromise(FsService.safeWriteFile(uri.toString(), content)); + return getRuntime().runPromise(FsService.safeWriteFile(uri, content)); } /** True if the file or folder exists. */ export function fileOrFolderExists(uri: Uri | string): Promise { const { FsService } = getServicesApi().services; - return getRuntime().runPromise(FsService.fileOrFolderExists(uri.toString())); + return getRuntime().runPromise(FsService.fileOrFolderExists(uri)); } /** Find files matching a glob, honoring the active (desktop or web) filesystem. */