From ea9d33a57eace5f94c0d8bc76695262c28fab73c Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:48:31 +0100 Subject: [PATCH 01/13] test(log-viewer): mock #vscode-elements through the jest config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 21 suites carried 35 copies of jest.mock('#vscode-elements/…', () => ({})). 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. That is a library-wide fact, not a per-suite decision, so it moves to moduleNameMapper. This widens the rule from opt-in to blanket. 38 suites reach a #vscode-elements import transitively and 21 already mocked, so 17 now get the stub where they previously loaded the real module. None assert vendor-element behaviour and the test count is unchanged. TimelineKey and DockLayout stubbed a whole component apiece to dodge the same jsdom problem. The mapper covers it, so both now exercise the real OverflowList and DetailDock. vscode-single-select is exempt: VsSelect extends VscodeSingleSelect and spreads its static styles, so a stub cannot be extended. VsSelect says so, and warns that mounting a under jsdom needs its own setFormValue shim. --- jest.config.js | 6 ++++++ log-viewer/src/__tests__/mocks/vscodeElementStub.ts | 10 ++++++++++ log-viewer/src/components/VsSelect.ts | 4 ++++ .../src/components/__tests__/AnchoredPopover.test.ts | 3 --- .../src/components/__tests__/CallTreeDetail.test.ts | 2 -- .../__tests__/CallTreeDetailScopedBuild.test.ts | 2 -- log-viewer/src/components/__tests__/CodeBlock.test.ts | 2 -- log-viewer/src/components/__tests__/DetailDock.test.ts | 3 --- log-viewer/src/components/__tests__/DockLayout.test.ts | 3 --- log-viewer/src/components/__tests__/HeaderMenu.test.ts | 3 --- log-viewer/src/components/__tests__/HotPath.test.ts | 2 -- .../src/components/__tests__/LogInspector.test.ts | 3 --- .../src/components/__tests__/LogProblemsChip.test.ts | 4 ---- log-viewer/src/components/__tests__/NavBar.test.ts | 5 ----- log-viewer/src/components/__tests__/PaneView.test.ts | 3 --- .../src/components/__tests__/VariablesDetail.test.ts | 4 +--- .../src/components/__tests__/ViewModeSwitch.test.ts | 4 ---- .../src/components/__tests__/skeletonStatus.test.ts | 4 ---- .../analysis/components/__tests__/AnalysisView.test.ts | 4 ---- .../components/__tests__/LogDiagnosticsView.test.ts | 2 -- .../src/features/app/__tests__/logLoadFailure.test.ts | 5 ----- .../components/__tests__/DatabaseOverview.test.ts | 2 -- .../components/__tests__/DatabaseRowBudget.test.ts | 1 - .../features/notifications/__tests__/IssueList.test.ts | 3 --- .../notifications/__tests__/NotificationCentre.test.ts | 4 ---- .../features/timeline/__tests__/TimelineKey.test.ts | 3 --- 26 files changed, 21 insertions(+), 70 deletions(-) create mode 100644 log-viewer/src/__tests__/mocks/vscodeElementStub.ts 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/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/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..a797b64dc 100644 --- a/log-viewer/src/components/__tests__/AnchoredPopover.test.ts +++ b/log-viewer/src/components/__tests__/AnchoredPopover.test.ts @@ -5,9 +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', () => ({})); - import type { AnchoredPopover } from '../AnchoredPopover.js'; import '../AnchoredPopover.js'; diff --git a/log-viewer/src/components/__tests__/CallTreeDetail.test.ts b/log-viewer/src/components/__tests__/CallTreeDetail.test.ts index 868b878db..684841157 100644 --- a/log-viewer/src/components/__tests__/CallTreeDetail.test.ts +++ b/log-viewer/src/components/__tests__/CallTreeDetail.test.ts @@ -18,8 +18,6 @@ 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'; diff --git a/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts b/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts index 173cfac11..911688385 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. diff --git a/log-viewer/src/components/__tests__/CodeBlock.test.ts b/log-viewer/src/components/__tests__/CodeBlock.test.ts index 2650779c0..9806d3d7d 100644 --- a/log-viewer/src/components/__tests__/CodeBlock.test.ts +++ b/log-viewer/src/components/__tests__/CodeBlock.test.ts @@ -5,8 +5,6 @@ */ import { beforeAll, describe, expect, it } from '@jest/globals'; -jest.mock('#vscode-elements/vscode-icon.js', () => ({})); - import type { CodeBlock } from '../CodeBlock.js'; import '../CodeBlock.js'; diff --git a/log-viewer/src/components/__tests__/DetailDock.test.ts b/log-viewer/src/components/__tests__/DetailDock.test.ts index c9539d306..55248bea9 100644 --- a/log-viewer/src/components/__tests__/DetailDock.test.ts +++ b/log-viewer/src/components/__tests__/DetailDock.test.ts @@ -6,9 +6,6 @@ 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 type { DetailDock } from '../DetailDock.js'; import '../DetailDock.js'; import type { PaneSection } from '../PaneView.js'; diff --git a/log-viewer/src/components/__tests__/DockLayout.test.ts b/log-viewer/src/components/__tests__/DockLayout.test.ts index edcab3e47..4a0a4d914 100644 --- a/log-viewer/src/components/__tests__/DockLayout.test.ts +++ b/log-viewer/src/components/__tests__/DockLayout.test.ts @@ -5,9 +5,6 @@ */ import { describe, expect, it, beforeAll } from '@jest/globals'; -// pulls in vscode-elements icons, which need APIs jsdom lacks. -jest.mock('../DetailDock.js', () => ({})); - import type { DockLayout } from '../DockLayout.js'; import '../DockLayout.js'; diff --git a/log-viewer/src/components/__tests__/HeaderMenu.test.ts b/log-viewer/src/components/__tests__/HeaderMenu.test.ts index c569a1344..ae8206825 100644 --- a/log-viewer/src/components/__tests__/HeaderMenu.test.ts +++ b/log-viewer/src/components/__tests__/HeaderMenu.test.ts @@ -5,9 +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', () => ({})); - import type { HeaderMenu } from '../HeaderMenu.js'; import '../HeaderMenu.js'; diff --git a/log-viewer/src/components/__tests__/HotPath.test.ts b/log-viewer/src/components/__tests__/HotPath.test.ts index 0f9f3b5d4..372c1c01d 100644 --- a/log-viewer/src/components/__tests__/HotPath.test.ts +++ b/log-viewer/src/components/__tests__/HotPath.test.ts @@ -8,8 +8,6 @@ import { beforeEach, describe, expect, it } from '@jest/globals'; 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, diff --git a/log-viewer/src/components/__tests__/LogInspector.test.ts b/log-viewer/src/components/__tests__/LogInspector.test.ts index cd156ad0f..413406ae4 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', () => ({})); diff --git a/log-viewer/src/components/__tests__/LogProblemsChip.test.ts b/log-viewer/src/components/__tests__/LogProblemsChip.test.ts index 0ac2c28fd..236f77965 100644 --- a/log-viewer/src/components/__tests__/LogProblemsChip.test.ts +++ b/log-viewer/src/components/__tests__/LogProblemsChip.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 type { IssueSeverity, LogIssue } from '../../features/notifications/types.js'; import type { LogProblemsChip } from '../LogProblemsChip.js'; diff --git a/log-viewer/src/components/__tests__/NavBar.test.ts b/log-viewer/src/components/__tests__/NavBar.test.ts index 3e2756a1d..a104ee843 100644 --- a/log-viewer/src/components/__tests__/NavBar.test.ts +++ b/log-viewer/src/components/__tests__/NavBar.test.ts @@ -5,11 +5,6 @@ */ 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 type { LogIdentityData } from '../../features/app/logIdentity.js'; import type { IssueSeverity, LogIssue } from '../../features/notifications/types.js'; diff --git a/log-viewer/src/components/__tests__/PaneView.test.ts b/log-viewer/src/components/__tests__/PaneView.test.ts index c12c7e108..650dd6e56 100644 --- a/log-viewer/src/components/__tests__/PaneView.test.ts +++ b/log-viewer/src/components/__tests__/PaneView.test.ts @@ -6,9 +6,6 @@ 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 type { PaneOrientation, PaneSection, PaneView } from '../PaneView.js'; import '../PaneView.js'; diff --git a/log-viewer/src/components/__tests__/VariablesDetail.test.ts b/log-viewer/src/components/__tests__/VariablesDetail.test.ts index ac3413599..e1e496af8 100644 --- a/log-viewer/src/components/__tests__/VariablesDetail.test.ts +++ b/log-viewer/src/components/__tests__/VariablesDetail.test.ts @@ -9,11 +9,9 @@ import { parse } from 'apex-log-parser'; import { MAX_MARKED_PER_VALUE } from '../../core/log/aggregateVariables.js'; import { logStoreFor, 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', () => { diff --git a/log-viewer/src/components/__tests__/ViewModeSwitch.test.ts b/log-viewer/src/components/__tests__/ViewModeSwitch.test.ts index d3bad7c70..fed20bb57 100644 --- a/log-viewer/src/components/__tests__/ViewModeSwitch.test.ts +++ b/log-viewer/src/components/__tests__/ViewModeSwitch.test.ts @@ -5,10 +5,6 @@ */ 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 type { ViewModeSwitch } from '../ViewModeSwitch.js'; import '../ViewModeSwitch.js'; 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/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..69e4d34a8 100644 --- a/log-viewer/src/features/analysis/components/__tests__/LogDiagnosticsView.test.ts +++ b/log-viewer/src/features/analysis/components/__tests__/LogDiagnosticsView.test.ts @@ -7,8 +7,6 @@ import { beforeEach, describe, expect, it } from '@jest/globals'; import type { LogDiagnostics } from '../../services/LogDiagnostics.js'; -jest.mock('#vscode-elements/vscode-icon.js', () => ({})); - let result: LogDiagnostics = { diagnostics: [], queryPlansKnown: 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..d97dc9ec2 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', () => ({})); 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..3e7093847 100644 --- a/log-viewer/src/features/database/components/__tests__/DatabaseOverview.test.ts +++ b/log-viewer/src/features/database/components/__tests__/DatabaseOverview.test.ts @@ -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', () => ({ 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..1390b112f 100644 --- a/log-viewer/src/features/database/components/__tests__/DatabaseRowBudget.test.ts +++ b/log-viewer/src/features/database/components/__tests__/DatabaseRowBudget.test.ts @@ -18,7 +18,6 @@ let budgets: RowBudgets; jest.mock('../../services/rowBudget.js', () => ({ rowBudgets: () => budgets, })); -jest.mock('#vscode-elements/vscode-icon.js', () => ({})); import '../DatabaseRowBudget.js'; import { settledNote } from '../../../../components/__tests__/sectionTestUtils.js'; diff --git a/log-viewer/src/features/notifications/__tests__/IssueList.test.ts b/log-viewer/src/features/notifications/__tests__/IssueList.test.ts index 47f73cb5d..b95879b13 100644 --- a/log-viewer/src/features/notifications/__tests__/IssueList.test.ts +++ b/log-viewer/src/features/notifications/__tests__/IssueList.test.ts @@ -5,9 +5,6 @@ */ 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 type { IssueAction, IssueSeverity, LogIssue } from '../types.js'; import type { IssueList } from '../components/IssueList.js'; diff --git a/log-viewer/src/features/notifications/__tests__/NotificationCentre.test.ts b/log-viewer/src/features/notifications/__tests__/NotificationCentre.test.ts index 5496a0f07..6305dd6ee 100644 --- a/log-viewer/src/features/notifications/__tests__/NotificationCentre.test.ts +++ b/log-viewer/src/features/notifications/__tests__/NotificationCentre.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 type { IssueSeverity, LogIssue } from '../types.js'; import type { NotificationCentre } from '../components/NotificationCentre.js'; diff --git a/log-viewer/src/features/timeline/__tests__/TimelineKey.test.ts b/log-viewer/src/features/timeline/__tests__/TimelineKey.test.ts index 9fb041c3a..83125bd25 100644 --- a/log-viewer/src/features/timeline/__tests__/TimelineKey.test.ts +++ b/log-viewer/src/features/timeline/__tests__/TimelineKey.test.ts @@ -5,9 +5,6 @@ */ 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 type { TimelineKeyEntry, Timelinekey } from '../components/TimelineKey.js'; import '../components/TimelineKey.js'; From c7d6e62ab04786436b242af2a76ecf3f20084427 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:51:00 +0100 Subject: [PATCH 02/13] test(log-viewer): mount lit elements through one helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 34 suites each wrote their own create, configure, append and await. They now call mountElement, which owns that sequence and throws a named error when a tag is not a rendered Lit element — an unregistered tag otherwise awaits an undefined updateComplete and fails later on a null shadowRoot. Three suites drop an `as unknown as { logStore }` cast: the cast was there only because the mount was typed LitElement, not the component's own exported class. Two suites settle one update more than before, because the helper always awaits the first render where their old helper went straight to a longer settle: LogInspector.test.ts and NamespaceTimeBar.test.ts. Both pass; extra settling cannot cause a false failure, but it can mask a render-timing regression. Left alone: skeletonStatus.test.ts mounts into a ContextProvider host rather than the body, and AnalysisView.test.ts constructs the view without ever adding it to the DOM. --- log-viewer/src/__tests__/helpers/mount.ts | 37 +++++++++++++++++++ .../__tests__/AnchoredPopover.test.ts | 18 ++++----- .../__tests__/CallStackDetail.test.ts | 10 ++--- .../__tests__/CallTreeDetail.test.ts | 16 ++++---- .../CallTreeDetailScopedBuild.test.ts | 14 ++----- .../components/__tests__/CodeBlock.test.ts | 10 ++--- .../components/__tests__/ColorSwatch.test.ts | 10 ++--- .../components/__tests__/DetailDock.test.ts | 10 ++--- .../components/__tests__/DockLayout.test.ts | 12 ++---- .../components/__tests__/EventVitals.test.ts | 10 ++--- .../__tests__/GovernorTrends.test.ts | 11 ++---- .../components/__tests__/HeaderMenu.test.ts | 14 ++----- .../src/components/__tests__/HotPath.test.ts | 12 +++--- .../src/components/__tests__/HotSpots.test.ts | 12 +++--- .../components/__tests__/LogInspector.test.ts | 5 +-- .../components/__tests__/LogOverview.test.ts | 8 +--- .../__tests__/LogProblemsChip.test.ts | 10 ++--- .../__tests__/NamespaceTimeBar.test.ts | 8 ++-- .../src/components/__tests__/NavBar.test.ts | 18 ++++----- .../src/components/__tests__/PaneView.test.ts | 16 ++++---- .../__tests__/SectionSkeleton.test.ts | 11 ++---- .../__tests__/StackedTimeBar.test.ts | 14 ++----- .../__tests__/VariablesDetail.test.ts | 9 +++-- .../__tests__/ViewModeSwitch.test.ts | 11 ++---- .../__tests__/LogDiagnosticsView.test.ts | 11 +++--- .../__tests__/SelfTimeSpreadView.test.ts | 12 +++--- .../app/__tests__/logLoadFailure.test.ts | 8 +--- .../__tests__/DatabaseOverview.test.ts | 18 +++------ .../__tests__/DatabaseRowBudget.test.ts | 16 +++----- .../components/__tests__/DatabaseView.test.ts | 14 +++---- .../__tests__/GovernorSummary.test.ts | 10 ++--- .../notifications/__tests__/IssueList.test.ts | 10 ++--- .../__tests__/NotificationCentre.test.ts | 10 ++--- .../timeline/__tests__/TimelineKey.test.ts | 10 ++--- 34 files changed, 177 insertions(+), 248 deletions(-) create mode 100644 log-viewer/src/__tests__/helpers/mount.ts 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/components/__tests__/AnchoredPopover.test.ts b/log-viewer/src/components/__tests__/AnchoredPopover.test.ts index a797b64dc..2f423fc41 100644 --- a/log-viewer/src/components/__tests__/AnchoredPopover.test.ts +++ b/log-viewer/src/components/__tests__/AnchoredPopover.test.ts @@ -5,19 +5,19 @@ */ import { describe, expect, it } from '@jest/globals'; +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 684841157..e1e394f99 100644 --- a/log-viewer/src/components/__tests__/CallTreeDetail.test.ts +++ b/log-viewer/src/components/__tests__/CallTreeDetail.test.ts @@ -21,6 +21,7 @@ jest.mock('tabulator-tables', () => ({ 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'; @@ -62,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 911688385..b6f662707 100644 --- a/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts +++ b/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts @@ -42,6 +42,7 @@ jest.mock('../scopedCallTree.js', () => ({ import { Tabulator, type RowComponent } from 'tabulator-tables'; +import { mountElement } from '../../__tests__/helpers/mount.js'; import type { CallTreeDetail } from '../CallTreeDetail.js'; import '../CallTreeDetail.js'; import { buildScopedCallTree, type ScopedCallTree, type ScopedRow } from '../scopedCallTree.js'; @@ -115,17 +116,8 @@ async function frame(el: CallTreeDetail): Promise { 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(() => { diff --git a/log-viewer/src/components/__tests__/CodeBlock.test.ts b/log-viewer/src/components/__tests__/CodeBlock.test.ts index 9806d3d7d..20d3e068c 100644 --- a/log-viewer/src/components/__tests__/CodeBlock.test.ts +++ b/log-viewer/src/components/__tests__/CodeBlock.test.ts @@ -5,16 +5,12 @@ */ import { beforeAll, describe, expect, it } from '@jest/globals'; +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 55248bea9..becd34d21 100644 --- a/log-viewer/src/components/__tests__/DetailDock.test.ts +++ b/log-viewer/src/components/__tests__/DetailDock.test.ts @@ -6,6 +6,7 @@ import { beforeAll, describe, expect, it } from '@jest/globals'; import { html } from 'lit'; +import { mountElement } from '../../__tests__/helpers/mount.js'; import type { DetailDock } from '../DetailDock.js'; import '../DetailDock.js'; import type { PaneSection } from '../PaneView.js'; @@ -15,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 4a0a4d914..22ed1e084 100644 --- a/log-viewer/src/components/__tests__/DockLayout.test.ts +++ b/log-viewer/src/components/__tests__/DockLayout.test.ts @@ -5,6 +5,7 @@ */ import { describe, expect, it, beforeAll } from '@jest/globals'; +import { mountElement } from '../../__tests__/helpers/mount.js'; import type { DockLayout } from '../DockLayout.js'; import '../DockLayout.js'; @@ -22,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..397fe9811 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 { diff --git a/log-viewer/src/components/__tests__/HeaderMenu.test.ts b/log-viewer/src/components/__tests__/HeaderMenu.test.ts index ae8206825..37f514b03 100644 --- a/log-viewer/src/components/__tests__/HeaderMenu.test.ts +++ b/log-viewer/src/components/__tests__/HeaderMenu.test.ts @@ -5,22 +5,16 @@ */ import { describe, expect, it } from '@jest/globals'; +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 372c1c01d..585a5bc2b 100644 --- a/log-viewer/src/components/__tests__/HotPath.test.ts +++ b/log-viewer/src/components/__tests__/HotPath.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'; @@ -35,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); diff --git a/log-viewer/src/components/__tests__/HotSpots.test.ts b/log-viewer/src/components/__tests__/HotSpots.test.ts index 874502943..ccb843b24 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,13 +35,10 @@ 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(() => { diff --git a/log-viewer/src/components/__tests__/LogInspector.test.ts b/log-viewer/src/components/__tests__/LogInspector.test.ts index 413406ae4..9d92359d2 100644 --- a/log-viewer/src/components/__tests__/LogInspector.test.ts +++ b/log-viewer/src/components/__tests__/LogInspector.test.ts @@ -76,6 +76,7 @@ jest.mock('../detailSections.js', () => ({ }, })); +import { mountElement } from '../../__tests__/helpers/mount.js'; import { eventBus, type DetailSource } from '../../core/events/EventBus.js'; import type { LogStore } from '../../core/log/LogStore.js'; import type { LogInspector } from '../LogInspector.js'; @@ -109,9 +110,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; } diff --git a/log-viewer/src/components/__tests__/LogOverview.test.ts b/log-viewer/src/components/__tests__/LogOverview.test.ts index fada16750..9c9b4c8bd 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) => { diff --git a/log-viewer/src/components/__tests__/LogProblemsChip.test.ts b/log-viewer/src/components/__tests__/LogProblemsChip.test.ts index 236f77965..df7dd2e03 100644 --- a/log-viewer/src/components/__tests__/LogProblemsChip.test.ts +++ b/log-viewer/src/components/__tests__/LogProblemsChip.test.ts @@ -5,6 +5,7 @@ */ import { describe, expect, it } from '@jest/globals'; +import { mountElement } from '../../__tests__/helpers/mount.js'; import type { IssueSeverity, LogIssue } from '../../features/notifications/types.js'; import type { LogProblemsChip } from '../LogProblemsChip.js'; @@ -22,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..52c116398 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; diff --git a/log-viewer/src/components/__tests__/NavBar.test.ts b/log-viewer/src/components/__tests__/NavBar.test.ts index a104ee843..dd5863204 100644 --- a/log-viewer/src/components/__tests__/NavBar.test.ts +++ b/log-viewer/src/components/__tests__/NavBar.test.ts @@ -5,6 +5,7 @@ */ import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'; +import { mountElement } from '../../__tests__/helpers/mount.js'; import type { LogIdentityData } from '../../features/app/logIdentity.js'; import type { IssueSeverity, LogIssue } from '../../features/notifications/types.js'; @@ -111,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 650dd6e56..2f210cc4f 100644 --- a/log-viewer/src/components/__tests__/PaneView.test.ts +++ b/log-viewer/src/components/__tests__/PaneView.test.ts @@ -6,6 +6,7 @@ import { beforeAll, describe, expect, it } from '@jest/globals'; import { html } from 'lit'; +import { mountElement } from '../../__tests__/helpers/mount.js'; import type { PaneOrientation, PaneSection, PaneView } from '../PaneView.js'; import '../PaneView.js'; @@ -19,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..072824ea6 100644 --- a/log-viewer/src/components/__tests__/StackedTimeBar.test.ts +++ b/log-viewer/src/components/__tests__/StackedTimeBar.test.ts @@ -5,23 +5,17 @@ */ import { beforeEach, 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) => diff --git a/log-viewer/src/components/__tests__/VariablesDetail.test.ts b/log-viewer/src/components/__tests__/VariablesDetail.test.ts index e1e496af8..fbdf16d95 100644 --- a/log-viewer/src/components/__tests__/VariablesDetail.test.ts +++ b/log-viewer/src/components/__tests__/VariablesDetail.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from '@jest/globals'; import { parse } from 'apex-log-parser'; +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'; @@ -65,10 +66,10 @@ function indexOf(store: LogStore, text: string): number { /** 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)); diff --git a/log-viewer/src/components/__tests__/ViewModeSwitch.test.ts b/log-viewer/src/components/__tests__/ViewModeSwitch.test.ts index fed20bb57..b2d83b353 100644 --- a/log-viewer/src/components/__tests__/ViewModeSwitch.test.ts +++ b/log-viewer/src/components/__tests__/ViewModeSwitch.test.ts @@ -5,6 +5,7 @@ */ import { describe, expect, it } from '@jest/globals'; +import { mountElement } from '../../__tests__/helpers/mount.js'; import type { ViewModeSwitch } from '../ViewModeSwitch.js'; import '../ViewModeSwitch.js'; @@ -18,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/features/analysis/components/__tests__/LogDiagnosticsView.test.ts b/log-viewer/src/features/analysis/components/__tests__/LogDiagnosticsView.test.ts index 69e4d34a8..11906f7df 100644 --- a/log-viewer/src/features/analysis/components/__tests__/LogDiagnosticsView.test.ts +++ b/log-viewer/src/features/analysis/components/__tests__/LogDiagnosticsView.test.ts @@ -5,6 +5,7 @@ */ import { beforeEach, describe, expect, it } from '@jest/globals'; +import { mountElement } from '../../../../__tests__/helpers/mount.js'; import type { LogDiagnostics } from '../../services/LogDiagnostics.js'; let result: LogDiagnostics = { @@ -34,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; 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..4b0bd3b3a 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,13 +40,10 @@ 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(); diff --git a/log-viewer/src/features/app/__tests__/logLoadFailure.test.ts b/log-viewer/src/features/app/__tests__/logLoadFailure.test.ts index d97dc9ec2..8e855f458 100644 --- a/log-viewer/src/features/app/__tests__/logLoadFailure.test.ts +++ b/log-viewer/src/features/app/__tests__/logLoadFailure.test.ts @@ -13,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'; @@ -27,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/database/components/__tests__/DatabaseOverview.test.ts b/log-viewer/src/features/database/components/__tests__/DatabaseOverview.test.ts index 3e7093847..28c54cde2 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 { @@ -194,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) => 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 1390b112f..aec0f4d83 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'; @@ -19,6 +19,7 @@ jest.mock('../../services/rowBudget.js', () => ({ rowBudgets: () => budgets, })); +import type { DatabaseRowBudget } from '../DatabaseRowBudget.js'; import '../DatabaseRowBudget.js'; import { settledNote } from '../../../../components/__tests__/sectionTestUtils.js'; @@ -66,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) => 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..c91dbe895 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,14 @@ 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), @@ -181,13 +181,11 @@ 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; + view = await mountElement('database-view'); }); afterEach(() => { 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..d46c5da80 100644 --- a/log-viewer/src/features/database/components/__tests__/GovernorSummary.test.ts +++ b/log-viewer/src/features/database/components/__tests__/GovernorSummary.test.ts @@ -7,16 +7,12 @@ import { beforeEach, describe, expect, it } from '@jest/globals'; import { NO_LIMIT_FOR_METRIC_TEXT } from '../../../../components/governorCopy.js'; import { formatInteger } from '../../../../core/utility/Util.js'; +import { mountElement } from '../../../../__tests__/helpers/mount.js'; import type { GaugeMetric, 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(() => { diff --git a/log-viewer/src/features/notifications/__tests__/IssueList.test.ts b/log-viewer/src/features/notifications/__tests__/IssueList.test.ts index b95879b13..a5d15211f 100644 --- a/log-viewer/src/features/notifications/__tests__/IssueList.test.ts +++ b/log-viewer/src/features/notifications/__tests__/IssueList.test.ts @@ -5,6 +5,7 @@ */ import { afterEach, beforeAll, describe, expect, it } from '@jest/globals'; +import { mountElement } from '../../../__tests__/helpers/mount.js'; import type { IssueAction, IssueSeverity, LogIssue } from '../types.js'; import type { IssueList } from '../components/IssueList.js'; @@ -27,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 6305dd6ee..8e9a936df 100644 --- a/log-viewer/src/features/notifications/__tests__/NotificationCentre.test.ts +++ b/log-viewer/src/features/notifications/__tests__/NotificationCentre.test.ts @@ -5,6 +5,7 @@ */ import { describe, expect, it } from '@jest/globals'; +import { mountElement } from '../../../__tests__/helpers/mount.js'; import type { IssueSeverity, LogIssue } from '../types.js'; import type { NotificationCentre } from '../components/NotificationCentre.js'; @@ -22,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/features/timeline/__tests__/TimelineKey.test.ts b/log-viewer/src/features/timeline/__tests__/TimelineKey.test.ts index 83125bd25..b797b77b8 100644 --- a/log-viewer/src/features/timeline/__tests__/TimelineKey.test.ts +++ b/log-viewer/src/features/timeline/__tests__/TimelineKey.test.ts @@ -5,16 +5,12 @@ */ import { describe, expect, it } from '@jest/globals'; +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') ?? [])]; From 3015230109daac4e96a553021e42f164ee39d2ca Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:21:18 +0100 Subject: [PATCH 03/13] test(log-viewer): build call tree events from one builder Four suites each wrote the same createEvent, differing only in which optional fields they exposed. One builder now serves all four. It carries the fields these passes actually read and no others, typed Partial rather than cast blind, so a renamed or reshaped field on the real class fails the typecheck here. Fourteen fields the old builders set and nothing reads are gone. The per-suite timestamp counter and its eight beforeEach resets are gone too: no module these suites drive reads event.timestamp, so the resets did nothing. eventText defaulted exitStamp to 1000, the others to null. null is the class default and models a frame that never exited, so it wins, and the two cases that read a duration now say exitStamp where they assert on it. Left alone: ExecutionHighlights derives exitStamp from a running stamp and needs eventIndex, and the timeline builders are positional and shape their duration differently. Roughly nine local event builders remain across the repo. --- log-viewer/src/__tests__/helpers/events.ts | 76 ++++++++++++++++ .../services/__tests__/CallStackSum.test.ts | 51 +---------- .../services/__tests__/RowGrouper.test.ts | 66 +------------- .../utils/__tests__/Aggregation.test.ts | 89 +------------------ .../utils/__tests__/eventText.test.ts | 52 ++--------- 5 files changed, 87 insertions(+), 247 deletions(-) create mode 100644 log-viewer/src/__tests__/helpers/events.ts 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/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/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, From 8d2d79e45a417942ecb8d5ac287a855d8262e4df Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:24:29 +0100 Subject: [PATCH 04/13] build: check every file with prettier The glob was unquoted, so the shell expanded it before prettier saw it. Without globstar that reaches 5 files; the repo holds 537 TypeScript files alone. Every nested path went unchecked. It stayed invisible because lint-staged writes prettier over staged files on the way into a commit, so the repo is formatted and the gate that was meant to prove it was reading almost nothing. Quoted, prettier expands the glob itself, as prettier-format already does. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From b6de8fc04ce397e007aa57a9d25962a7e8f1ba58 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:24:53 +0100 Subject: [PATCH 05/13] chore: ignore a pnpm store inside the repo pnpm puts its store in .pnpm-store here when the usual location is not writable, which leaves 947MB untracked and one git add -A away from the history. --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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/ From 4da51d10e1642c03b2e153257d7f8549fc16b073 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:27:04 +0100 Subject: [PATCH 06/13] test(log-viewer): clear the document body after every test A mounted component stayed connected once its test ended, and the listeners its connectedCallback registered stayed live with it. A suite that emitted on the eventBus after several mounts reached every one of them. Cleanup was per suite and ad hoc: sixteen hooks cleared the body, four of them doing nothing else, and the rest of the jsdom suites cleared nothing. setup.ts now does it once for all of them, guarded because the suites run under node unless a file asks for jsdom. replaceChildren rather than innerHTML, so disconnectedCallback runs and the subscriptions release. --- log-viewer/src/__tests__/setup.ts | 15 +++++++++++++++ .../__tests__/CallTreeDetailScopedBuild.test.ts | 1 - .../components/__tests__/GovernorTrends.test.ts | 1 - .../src/components/__tests__/HotPath.test.ts | 1 - .../src/components/__tests__/HotSpots.test.ts | 1 - .../src/components/__tests__/LogInspector.test.ts | 1 - .../src/components/__tests__/LogOverview.test.ts | 1 - .../components/__tests__/NamespaceTimeBar.test.ts | 1 - .../components/__tests__/StackedTimeBar.test.ts | 6 +----- .../__tests__/LogDiagnosticsView.test.ts | 1 - .../__tests__/SelfTimeSpreadView.test.ts | 1 - .../features/app/__tests__/escapeDeselect.test.ts | 4 ---- .../components/__tests__/DatabaseOverview.test.ts | 2 -- .../__tests__/DatabaseRowBudget.test.ts | 1 - .../components/__tests__/DatabaseView.test.ts | 7 ------- .../components/__tests__/GovernorSummary.test.ts | 6 +----- .../timeline/__tests__/timeline-dispose.test.ts | 1 - 17 files changed, 17 insertions(+), 34 deletions(-) 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/__tests__/CallTreeDetailScopedBuild.test.ts b/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts index b6f662707..6da27de21 100644 --- a/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts +++ b/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts @@ -121,7 +121,6 @@ const mount = (eventIndex: number, sourceView?: 'callers' | 'callees'): Promise< 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__/GovernorTrends.test.ts b/log-viewer/src/components/__tests__/GovernorTrends.test.ts index 397fe9811..65d7317b0 100644 --- a/log-viewer/src/components/__tests__/GovernorTrends.test.ts +++ b/log-viewer/src/components/__tests__/GovernorTrends.test.ts @@ -70,7 +70,6 @@ let seeks: { timestamp?: number; mode?: string }[]; let unsubscribe: () => void; beforeEach(() => { - document.body.replaceChildren(); series = [trend()]; seeks = []; unsubscribe?.(); diff --git a/log-viewer/src/components/__tests__/HotPath.test.ts b/log-viewer/src/components/__tests__/HotPath.test.ts index 585a5bc2b..e36a628e9 100644 --- a/log-viewer/src/components/__tests__/HotPath.test.ts +++ b/log-viewer/src/components/__tests__/HotPath.test.ts @@ -57,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 ccb843b24..1dcd0eadc 100644 --- a/log-viewer/src/components/__tests__/HotSpots.test.ts +++ b/log-viewer/src/components/__tests__/HotSpots.test.ts @@ -42,7 +42,6 @@ const hotSpots = () => 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 9d92359d2..b14d6cd46 100644 --- a/log-viewer/src/components/__tests__/LogInspector.test.ts +++ b/log-viewer/src/components/__tests__/LogInspector.test.ts @@ -225,7 +225,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 () => { diff --git a/log-viewer/src/components/__tests__/LogOverview.test.ts b/log-viewer/src/components/__tests__/LogOverview.test.ts index 9c9b4c8bd..018033280 100644 --- a/log-viewer/src/components/__tests__/LogOverview.test.ts +++ b/log-viewer/src/components/__tests__/LogOverview.test.ts @@ -31,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__/NamespaceTimeBar.test.ts b/log-viewer/src/components/__tests__/NamespaceTimeBar.test.ts index 52c116398..da5a47979 100644 --- a/log-viewer/src/components/__tests__/NamespaceTimeBar.test.ts +++ b/log-viewer/src/components/__tests__/NamespaceTimeBar.test.ts @@ -48,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__/StackedTimeBar.test.ts b/log-viewer/src/components/__tests__/StackedTimeBar.test.ts index 072824ea6..364b30aa7 100644 --- a/log-viewer/src/components/__tests__/StackedTimeBar.test.ts +++ b/log-viewer/src/components/__tests__/StackedTimeBar.test.ts @@ -3,7 +3,7 @@ * * @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'; @@ -23,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/features/analysis/components/__tests__/LogDiagnosticsView.test.ts b/log-viewer/src/features/analysis/components/__tests__/LogDiagnosticsView.test.ts index 11906f7df..4a8d3230a 100644 --- a/log-viewer/src/features/analysis/components/__tests__/LogDiagnosticsView.test.ts +++ b/log-viewer/src/features/analysis/components/__tests__/LogDiagnosticsView.test.ts @@ -62,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 4b0bd3b3a..27dfbd1bd 100644 --- a/log-viewer/src/features/analysis/components/__tests__/SelfTimeSpreadView.test.ts +++ b/log-viewer/src/features/analysis/components/__tests__/SelfTimeSpreadView.test.ts @@ -50,7 +50,6 @@ const text = (element: Element, selector: string) => describe('self-time-spread', () => { beforeEach(() => { - document.body.replaceChildren(); spread = null; }); 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/database/components/__tests__/DatabaseOverview.test.ts b/log-viewer/src/features/database/components/__tests__/DatabaseOverview.test.ts index 28c54cde2..5db46afba 100644 --- a/log-viewer/src/features/database/components/__tests__/DatabaseOverview.test.ts +++ b/log-viewer/src/features/database/components/__tests__/DatabaseOverview.test.ts @@ -259,7 +259,6 @@ describe('databaseTreeRows', () => { describe('database-concentration', () => { beforeEach(() => { - document.body.replaceChildren(); overview = null; }); @@ -382,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 aec0f4d83..bdd13672c 100644 --- a/log-viewer/src/features/database/components/__tests__/DatabaseRowBudget.test.ts +++ b/log-viewer/src/features/database/components/__tests__/DatabaseRowBudget.test.ts @@ -91,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 c91dbe895..bcf9efe47 100644 --- a/log-viewer/src/features/database/components/__tests__/DatabaseView.test.ts +++ b/log-viewer/src/features/database/components/__tests__/DatabaseView.test.ts @@ -57,7 +57,6 @@ describe('database-view selection', () => { let off: () => void; beforeEach(async () => { - document.body.replaceChildren(); view = await mountElement('database-view'); grids = { dml: fakeGrid('dml-view'), @@ -71,7 +70,6 @@ describe('database-view selection', () => { afterEach(() => { off(); - document.body.replaceChildren(); }); /** The grids report upward; DatabaseView alone turns that into a selection. */ @@ -184,14 +182,9 @@ describe('database-view find totals', () => { let view: LitElement; beforeEach(async () => { - document.body.replaceChildren(); view = await mountElement('database-view'); }); - afterEach(() => { - document.body.replaceChildren(); - }); - /** The totals DatabaseView rolls up to the find widget while `run` happens. */ function rollUps(run: () => void): number[] { const seen: number[] = []; 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 d46c5da80..834331494 100644 --- a/log-viewer/src/features/database/components/__tests__/GovernorSummary.test.ts +++ b/log-viewer/src/features/database/components/__tests__/GovernorSummary.test.ts @@ -3,7 +3,7 @@ * * @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'; @@ -15,10 +15,6 @@ 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([ 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', () => { From 7e3022d1cb9b153b1f267ae6489db3532adf0b61 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:28:11 +0100 Subject: [PATCH 07/13] test(log-viewer): wait a frame through the helper that owns it core/utility/FrameBudget.ts exports waitForNextFrame, and six sites in three suites wrote it out again. The tests now yield through the same helper the code under test yields through. The setTimeout(0) waits stay as they are: waitForNextTask posts through a MessageChannel, which does not order the same way against a timer. --- .../__tests__/CallTreeDetailScopedBuild.test.ts | 3 ++- log-viewer/src/components/__tests__/LogInspector.test.ts | 9 +++++---- .../metric-strip/MetricStripTooltipRenderer.test.ts | 5 ++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts b/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts index 6da27de21..472435928 100644 --- a/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts +++ b/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts @@ -43,6 +43,7 @@ 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'; @@ -112,7 +113,7 @@ 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); } diff --git a/log-viewer/src/components/__tests__/LogInspector.test.ts b/log-viewer/src/components/__tests__/LogInspector.test.ts index b14d6cd46..2de60b82a 100644 --- a/log-viewer/src/components/__tests__/LogInspector.test.ts +++ b/log-viewer/src/components/__tests__/LogInspector.test.ts @@ -77,6 +77,7 @@ 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'; @@ -100,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); } @@ -392,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); @@ -855,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/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)]; From b72e3d20dad9dcdc5aa08af0e6080baf72c36e29 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:12:55 +0100 Subject: [PATCH 08/13] test(log-viewer): parse test logs through one envelope Three suites each wrapped a body in the same header, execution and code unit, then parsed it. The envelope, the settings headers and the two eventsById lookups now have one home. The bodies stay in the tests. A body is what its test is about, and the whole input should read where it is asserted on; only the constant wrapper moved. The settings header takes its level from LOG_LEVEL, the enum recordsVariables branches on, so the fixture and the code under test cannot drift apart. VariablesDetail held a third and fourth copy of the isParent lookup and a local indexOf taking a store where the sibling suite's took a log. One name, one signature now: call sites pass store.log. --- log-viewer/src/__tests__/helpers/apexLog.ts | 65 ++++++++ .../__tests__/VariablesDetail.test.ts | 154 +++++++----------- .../log/__tests__/aggregateVariables.test.ts | 26 +-- .../core/log/__tests__/frameVariables.test.ts | 43 +---- 4 files changed, 136 insertions(+), 152 deletions(-) create mode 100644 log-viewer/src/__tests__/helpers/apexLog.ts 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/components/__tests__/VariablesDetail.test.ts b/log-viewer/src/components/__tests__/VariablesDetail.test.ts index fbdf16d95..9ebb92d0f 100644 --- a/log-viewer/src/components/__tests__/VariablesDetail.test.ts +++ b/log-viewer/src/components/__tests__/VariablesDetail.test.ts @@ -4,11 +4,11 @@ * @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 (the soql formatter). The // raw value it renders is covered by variableValue's own tests. @@ -31,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' + @@ -55,15 +41,6 @@ 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 = await mountElement('variables-detail', { @@ -116,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( @@ -144,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(); @@ -157,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(); @@ -169,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.']); }); @@ -179,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.']); }); @@ -191,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'); @@ -236,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']); }); @@ -244,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([ @@ -260,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; @@ -272,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'); }); @@ -284,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'); @@ -298,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'); @@ -313,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(); @@ -335,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'); }); @@ -344,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:'); }); @@ -357,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'); }); @@ -371,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'); @@ -391,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'); @@ -414,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. @@ -437,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(); }); @@ -453,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'); @@ -470,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'); }); @@ -483,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'); @@ -506,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'); @@ -521,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'); @@ -529,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); @@ -541,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') { @@ -558,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'); @@ -569,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); @@ -581,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, '*'); @@ -595,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'); @@ -606,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 }); @@ -619,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. @@ -631,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 }); @@ -640,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); @@ -659,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(); @@ -678,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 })); @@ -690,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(); @@ -706,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); @@ -724,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(); @@ -745,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') @@ -777,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'); @@ -788,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"'); @@ -805,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(); @@ -823,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) @@ -840,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(); @@ -865,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)); @@ -944,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 })); @@ -960,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 })); @@ -974,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. @@ -1054,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); }); @@ -1074,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/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', () => { From cf7caa3701794f74c1366152cc464c947101eee2 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Sat, 19 Sep 2026 01:03:06 +0100 Subject: [PATCH 09/13] test(log-viewer): share the timeline viewport and chart doubles Four test files each built their own ViewportState factory and two built the same FlameChart internals stub. makeViewport and stubChartInternals give both one home. stubChartInternals starts every renderDirty flag false, unlike the real init(), so a case proves the render it asked for. HoverRehit passes { culling: true } where it depends on one. internalsOf names the cast past the public API once, in place of the eight copies the two chart suites carried. --- .../src/__tests__/helpers/flameChart.ts | 56 +++++++++++ log-viewer/src/__tests__/helpers/viewport.ts | 19 ++++ .../timeline/__tests__/event-index.test.ts | 49 ++++------ .../__tests__/search-highlight.test.ts | 92 +++---------------- .../__tests__/FlameChartResize.test.ts | 43 ++------- .../optimised/__tests__/HighlightHalo.test.ts | 11 +-- .../optimised/__tests__/HoverRehit.test.ts | 38 ++------ .../optimised/__tests__/HoverWash.test.ts | 11 +-- .../__tests__/RectangleCache.bucket.test.ts | 57 +++++------- .../__tests__/TemporalSegmentTree.test.ts | 75 ++++++--------- 10 files changed, 181 insertions(+), 270 deletions(-) create mode 100644 log-viewer/src/__tests__/helpers/flameChart.ts create mode 100644 log-viewer/src/__tests__/helpers/viewport.ts 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/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/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__/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/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) From cea979e355f46b02faf4acc46afe4ad569548726 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:45:07 +0100 Subject: [PATCH 10/13] test(lana): share the context cast and the command harvest asContext replaces 67 hand-written casts of a MockContext to Context, and lastRegisteredCommand replaces three copies of the expression that digs the handler out of registerCommand's last call. Both were already written once, privately, in WhatsNewNotification's suite. Fifteen beforeEach blocks cleared the mocks again. setup.ts clears them in a global beforeEach, which runs first, so the second clear did nothing. Three of the fifteen had nothing else to do. --- lana/src/__tests__/Main.test.ts | 6 +- lana/src/__tests__/helpers/test-builders.ts | 30 ++++++- .../src/cache/__tests__/LogEventCache.test.ts | 7 +- lana/src/commands/__tests__/Command.test.ts | 23 ++---- lana/src/commands/__tests__/LogView.test.ts | 21 ++--- .../__tests__/RetrieveLogFile.test.ts | 39 ++++----- .../__tests__/ShowInLogAnalysis.test.ts | 17 ++-- .../__tests__/ShowLogAnalysis.test.ts | 17 ++-- .../__tests__/SwitchTimelineTheme.test.ts | 82 +++++-------------- .../__tests__/RawLogLineDecoration.test.ts | 9 +- .../__tests__/OpenFileInPackage.test.ts | 15 +--- .../__tests__/WhatsNewNotification.test.ts | 7 +- .../__tests__/RawLogFoldingProvider.test.ts | 9 +- .../__tests__/ApexLogLanguageDetector.test.ts | 26 ++---- .../codesymbol/__tests__/SfdxProject.test.ts | 1 - .../__tests__/SfdxProjectReader.test.ts | 1 - .../codesymbol/__tests__/SymbolFinder.test.ts | 4 - .../__tests__/salesforceServices.test.ts | 1 - .../__tests__/servicesRuntime.test.ts | 1 - .../__tests__/RawLogSymbolProvider.test.ts | 5 +- .../workspace/__tests__/VSWorkspace.test.ts | 1 - .../__tests__/VSWorkspaceManager.test.ts | 1 - 22 files changed, 132 insertions(+), 191 deletions(-) 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/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/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; }); From d0142b24dcf9b64e09cb4dd1d2ad3a90d1cd19b3 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:17:01 +0100 Subject: [PATCH 11/13] test: collapse literal-only cases into it.each Groups of it() that differed by one literal become tables, so a new case is a row. The tier boundaries were written out against two components; they are governorTier's own, and are now stated once with it. Two cases go. One repeated the first TIMESTAMP_REGEX line byte for byte and asserted less of it. The other read 79 where the constant it stands under is 80, so moving the band would have failed it for no reason a reader can see. --- lana/src/__tests__/log-utils.test.ts | 181 ++++-------------- .../salesforce/__tests__/ApexVisitor.test.ts | 8 +- .../__tests__/GovernorTrends.test.ts | 13 +- .../src/core/utility/__tests__/Util.test.ts | 65 ++----- .../__tests__/GovernorSummary.test.ts | 33 +++- .../__tests__/keyboard-handler.test.ts | 83 +++----- 6 files changed, 115 insertions(+), 268 deletions(-) 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/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/log-viewer/src/components/__tests__/GovernorTrends.test.ts b/log-viewer/src/components/__tests__/GovernorTrends.test.ts index 65d7317b0..94822730b 100644 --- a/log-viewer/src/components/__tests__/GovernorTrends.test.ts +++ b/log-viewer/src/components/__tests__/GovernorTrends.test.ts @@ -300,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/core/utility/__tests__/Util.test.ts b/log-viewer/src/core/utility/__tests__/Util.test.ts index e9903a640..c2f898267 100644 --- a/log-viewer/src/core/utility/__tests__/Util.test.ts +++ b/log-viewer/src/core/utility/__tests__/Util.test.ts @@ -6,58 +6,33 @@ import { describe, expect, it } from '@jest/globals'; import { computeWallClockMs, formatByteSize, 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); - }); - - 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); - }); + // 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 an event 1 second later', () => { - // 1 second = 1,000,000,000 ns - const result = computeWallClockMs(37764600, 6329577, 1006329577); - expect(result).toBe(37765600); + 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 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); }); }); 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 834331494..35d0f1031 100644 --- a/log-viewer/src/features/database/components/__tests__/GovernorSummary.test.ts +++ b/log-viewer/src/features/database/components/__tests__/GovernorSummary.test.ts @@ -8,7 +8,12 @@ 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 { mountElement } from '../../../../__tests__/helpers/mount.js'; -import type { GaugeMetric, GovernorSummary } from '../GovernorSummary.js'; +import { + GOVERNOR_WARN_PERCENT, + governorTier, + type GaugeMetric, + type GovernorSummary, +} from '../GovernorSummary.js'; import '../GovernorSummary.js'; const strip = (metrics: GaugeMetric[]) => @@ -88,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/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 }); From 3521e62e099a7d0e291dae78a72aea962646f7b3 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:17:30 +0100 Subject: [PATCH 12/13] build: keep prettier out of the worktrees A live worktree is a second copy of the repo inside it, so quoting the glob put every one of them in the gate. Running lint on main then fails naming a file on another branch, which lint-staged never formats and main cannot fix. --- .prettierignore | 4 ++++ 1 file changed, 4 insertions(+) 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/ From d17ab8fb33761e35dfb622339aa2c0bf4f4a981c Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:17:50 +0100 Subject: [PATCH 13/13] test(log-viewer): move stray suites beside their subjects Four suites sat under src/__tests__ rather than next to the code they cover, and two of them tested the same Util. src/__tests__ now holds only setup.ts, the helpers and the mocks, so the next Util case has one place to go. SOQLLinter's operator cases collapse into a table on the way past; the move is what brought the file in reach. --- log-viewer/src/__tests__/Util.test.ts | 94 ------------------ .../utility}/__tests__/EventSearch.test.ts | 2 +- .../src/core/utility/__tests__/Util.test.ts | 95 ++++++++++++++++++- .../services/__tests__}/SOQLLinter.test.ts | 44 ++------- .../services}/__tests__/SOQLParser.test.ts | 2 +- 5 files changed, 104 insertions(+), 133 deletions(-) delete mode 100644 log-viewer/src/__tests__/Util.test.ts rename log-viewer/src/{ => core/utility}/__tests__/EventSearch.test.ts (94%) rename log-viewer/src/{__tests__/soql => features/soql/services/__tests__}/SOQLLinter.test.ts (83%) rename log-viewer/src/{ => features/soql/services}/__tests__/SOQLParser.test.ts (96%) 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__/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 c2f898267..c03773da8 100644 --- a/log-viewer/src/core/utility/__tests__/Util.test.ts +++ b/log-viewer/src/core/utility/__tests__/Util.test.ts @@ -3,7 +3,12 @@ */ 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.each([ @@ -55,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/__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 () => {