Files
openobserve/web/src/plugins/traces/TraceTree.spec.ts
T
Omkar Kesarkhaneandgithub-actions[bot] fac24d59bb fix(traces): trace-log correlation fixes and Trace Graph height (#13901)
## Problem

Four defects around traces and the trace↔log correlation flows:

1. **Trace Graph is clipped.** The service map renders inside a 200px
band, vertically centred in an otherwise empty pane, instead of using
the available height.
2. **A span row's View Logs goes nowhere.** In OSS builds it navigates
to `/logs?stream=&from=NaN&to=NaN`; the logs page drops the filter and
falls back to its default window, so the user gets an unfiltered result
set instead of that span's logs.
3. **The log detail drawer has no View Trace button.** Opening the trace
for a log record only works from the inline expanded row.
4. **View Trace lived inside the JSON tab**, so it disappeared when
switching to Table.

## Root causes

**Trace Graph.** The ancestor chain was already correct — the sibling
Waterfall tab fills the same box. Only the leaf was clamped: the chart
carried `h-50! min-h-50!` (12.5rem, `!important`), which beat its own
root `h-full w-full`. That clamp dates to when the map lived in a small
collapsible timeline strip, where 200px was the intended design. When it
was promoted to a full-height tab, the scoped rule `.trace-details
.trace-chart-height { height: 12.5rem }` was left behind and its
two-class specificity silently defeated the `h-full!` added at the time.
The later design-token migration deleted the rule but preserved the
rendered result by translating it into utilities, which made the clamp
look deliberate.

**Span View Logs.** `buildQueryDetails()` reads the raw API shape
(`start_time`/`end_time`, nanoseconds), but the waterfall passes
formatted nodes, which `getFormattedSpan()` rebuilds as a fresh object
literal — camelCase, times as `startTimeUs`/`endTimeUs` in microseconds,
snake_case originals dropped. Reading `start_time` off one yields
`undefined`, hence `NaN`. The helper already had a `spanId || span_id`
fallback for the id, which is why the query string was correct while the
window was not.

**Drawer View Trace.** `JsonPreview` decides whether to offer the action
once, in `onBeforeMount`. `DetailTable` fills `rowData` in an `async
created()` — the `await` defers assignment past the child's mount even
though the work is synchronous — so the preview mounted with an empty
record, the check failed, the traces streams were never fetched, and
nothing recomputed it.

## Changes

- Trace Graph chart uses `h-full`; the orphaned `trace-chart-height`
class is dropped (its rule no longer exists) and two nested wrappers
collapse into one. Matches the full-bleed pattern in `ServiceGraph.vue`.
- The span row resolves the raw span from `spanMap` before calling the
helper — the same lookup this component already does for span data, HTTP
status and events — rather than teaching the shared helper to accept two
shapes. Guarded, since a span missing `span_id` gets a synthetic id
absent from the map.
- The span row also warns instead of navigating when no logs stream is
selected. The header's own View Logs button is disabled in that state
but the row-level one is not, so it was a silent dead end.
- `JsonPreview` re-evaluates the View Trace gate when the previewed
record changes, so the flag tracks its input rather than a single
snapshot.
- The action moves to the drawer's header row beside the wrap toggle,
reachable from every tab, with `hide-view-trace` on `JsonPreview`
(mirroring the existing `hide-view-related`). The gate, stream fetch and
filter move into `useViewTraceAction` rather than being copied into
`DetailTable` — `service_streams_enabled` is already hand-rolled in
three files and drifted. `JsonPreview` keeps exposing the same names, so
its specs are untouched.

Two latent issues surfaced while moving the action:

- `hiddenMenus` is seeded as an array and only becomes a `Set` once
`MainLayout` has read `custom_hide_menus`, so `.has()` is not always
available. Both shapes are now accepted.
- `OSelect`'s root sets `w-full`, which won a plain `w-40` and pushed
the button outside the drawer — the same class-collision shape as defect
1. Pinned with `w-40!`.

## Tests

No behaviour is asserted by new specs; existing suites cover the touched
paths and pass unmodified:

- `JsonPreview.spec.ts` — 91 passed (unchanged, including the assertions
on `tracesStreams`)
- `DetailTable.spec.ts` — 70 passed, 3 skipped
- `TraceTree.spec.ts`, `useTraces.spec.ts`,
`TraceDetailsSidebar.spec.ts` — 297 passed
- `TraceDetails` suites — 234 passed
- `SearchResult.viewTrace.spec.ts`,
`CorrelatedLogsTable.viewTrace.spec.ts` — passed

Prettier and ESLint are clean on every touched file.

## Verification

Checked against live trace and log data sharing a trace id:

- Trace Graph fills its pane (measured 549px where it previously
measured 200px), survives a tab round-trip, and reflows on window
resize.
- A span row's View Logs lands on `/logs` with the span+trace filter and
a ±60s window whose bounds match the span's real timestamps exactly;
with no stream selected it warns and does not navigate.
- The drawer's header shows the picker and button on both the JSON and
Table tabs with no overflow, and opens the trace with the correct id and
a ±15 min window.
- Console clean across every navigation.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-21 05:58:02 +00:00

1607 lines
50 KiB
TypeScript

// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
// Shared, stable spy so call assertions survive the virtualizer computed being
// re-evaluated on every read (the mock recreates its object each access).
const { mockScrollToIndex } = vi.hoisted(() => ({
mockScrollToIndex: vi.fn(),
}));
// Mock @tanstack/vue-virtual BEFORE any imports so the virtualizer returns
// synthetic virtual rows even when scrollContainer is null (as in jsdom).
vi.mock("@tanstack/vue-virtual", () => ({
useVirtualizer: vi.fn((optionsComputed: any) => {
const { computed } = require("vue");
return computed(() => {
const options = optionsComputed.value;
const count = options.count;
const estimateSize = options.estimateSize?.() ?? 30;
const items = Array.from({ length: count }, (_, i) => ({
key: i,
index: i,
start: i * estimateSize,
end: (i + 1) * estimateSize,
size: estimateSize,
lane: 0,
}));
return {
getVirtualItems: () => items,
getTotalSize: () => count * estimateSize,
scrollToIndex: mockScrollToIndex,
currentScrollToIndex: null,
};
});
}),
}));
vi.mock("@/utils/traces/convertTraceData", () => ({
getServiceIconDataUrl: vi.fn().mockReturnValue("data:image/svg+xml;base64,ICON"),
getSpanTechIconDataUrl: vi.fn().mockReturnValue(null),
}));
// Shared, stable spies for the useTraces helpers so `viewSpanLogs` can be
// asserted on. `searchObj` is a plain mutable object: tests flip
// `data.traceDetails.selectedLogStreams` to drive the OSS stream guard.
const { mockBuildQueryDetails, mockNavigateToLogs, mockToast, mockSearchObj } = vi.hoisted(() => ({
mockBuildQueryDetails: vi.fn(),
mockNavigateToLogs: vi.fn(),
mockToast: vi.fn(),
mockSearchObj: {
meta: { serviceColors: {} },
data: { traceDetails: { selectedLogStreams: [] as string[] } },
},
}));
vi.mock("@/composables/useTraces", () => ({
default: () => ({
searchObj: mockSearchObj,
buildQueryDetails: mockBuildQueryDetails,
navigateToLogs: mockNavigateToLogs,
}),
}));
vi.mock("@/lib/feedback/Toast/useToast", async () => {
const actual = await vi.importActual<Record<string, unknown>>("@/lib/feedback/Toast/useToast");
return { ...actual, toast: mockToast };
});
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import i18n from "@/locales";
import router from "@/test/unit/helpers/router";
import { createStore } from "vuex";
import TraceTree from "@/plugins/traces/TraceTree.vue";
import config from "@/aws-exports";
const mockStore = createStore({
state: {
theme: "light",
API_ENDPOINT: "http://localhost:8080",
zoConfig: {
timestamp_column: "@timestamp",
},
selectedOrganization: {
identifier: "test-org",
},
},
});
const mockSpans = [
{
spanId: "d9603ec7f76eb499",
operationName: "service:alerts:evaluate_scheduled",
serviceName: "scheduler",
resolvedIdentity: "scheduler",
spanStatus: "UNSET",
spanKind: "Client",
parentId: "6702b0494b2b6e57",
hasChildSpans: true,
style: {
color: "#1ab8be",
backgroundColor: "#1ab8be33",
top: "0px",
left: "0px",
},
depth: 0,
index: 0,
},
{
spanId: "6702b0494b2b6e57",
operationName: "service:alerts:process",
serviceName: "scheduler",
resolvedIdentity: "scheduler",
spanStatus: "ERROR",
spanKind: "Server",
parentId: null,
hasChildSpans: false,
style: {
color: "#ff6b6b",
backgroundColor: "#ff6b6b33",
top: "60px",
left: "0px",
},
depth: 0,
index: 1,
},
];
const mockSpanMap = {
d9603ec7f76eb499: {
_timestamp: 1752490492843047,
start_time: 1752490492843047200,
end_time: 1752490493164419300,
duration: 321372,
span_id: "d9603ec7f76eb499",
operation_name: "service:alerts:evaluate_scheduled",
service_name: "scheduler",
span_status: "UNSET",
span_kind: 2,
parent_id: "6702b0494b2b6e57",
},
"6702b0494b2b6e57": {
_timestamp: 1752490492843047,
start_time: 1752490492843047200,
end_time: 1752490493164419300,
duration: 321372,
span_id: "6702b0494b2b6e57",
operation_name: "service:alerts:process",
service_name: "scheduler",
span_status: "ERROR",
span_kind: 1,
parent_id: null,
},
};
const mockBaseTracePosition = {
durationMs: 350.372,
startTimeMs: 1752490492843,
tics: [
{
value: 0,
label: "0.00us",
left: "-1px",
},
{
value: 80.34,
label: "80.34ms",
left: "25%",
},
{
value: 160.69,
label: "160.69ms",
left: "50%",
},
{
value: 241.03,
label: "241.03ms",
left: "75%",
},
{
value: 321.37,
label: "321.37ms",
left: "100%",
},
],
};
const mockSpanDimensions = {
height: 30,
barHeight: 8,
textHeight: 25,
gap: 15,
collapseHeight: "14",
collapseWidth: 14,
connectorPadding: 2,
paddingLeft: 8,
hConnectorWidth: 20,
dotConnectorWidth: 6,
dotConnectorHeight: 6,
colors: ["#b7885e", "#1ab8be", "#ffcb99", "#f89570", "#839ae2"],
};
const mockSpanList = [
{
span_id: "d9603ec7f76eb499",
service_name: "scheduler",
operation_name: "service:alerts:evaluate_scheduled",
duration: 321372,
span_status: "UNSET",
links: [
{
context: {
traceId: "f6e08ab2a928aa393375f0d9b05a9054",
spanId: "ecc59cb843104cf8",
},
},
{
context: {
traceId: "6262666637a9ae45ad3e25f5111dd59f",
spanId: "d9603ec7f76eb499",
},
},
],
},
{
span_id: "6702b0494b2b6e57",
service_name: "scheduler",
operation_name: "service:alerts:process",
duration: 321372,
span_status: "ERROR",
links: [],
},
];
function mountTraceTree(extraProps: Record<string, unknown> = {}, storePlugin = mockStore) {
return mount(TraceTree, {
props: {
spans: mockSpans,
isCollapsed: false,
collapseMapping: {},
baseTracePosition: mockBaseTracePosition,
depth: 0,
spanDimensions: mockSpanDimensions,
spanMap: mockSpanMap,
leftWidth: 300,
searchQuery: "",
spanList: mockSpanList,
...extraProps,
},
global: {
plugins: [i18n, router, storePlugin],
stubs: {
"span-block": true,
},
},
});
}
describe("TraceTree", () => {
let wrapper: any;
beforeEach(() => {
wrapper = mountTraceTree();
});
afterEach(() => {
wrapper.unmount();
vi.clearAllMocks();
});
it("should mount TraceTree component", () => {
expect(wrapper.exists()).toBe(true);
});
it("should render all spans", () => {
const spanElements = wrapper.findAll(`[data-test^="trace-tree-span-container-"]`);
expect(spanElements.length).toBe(mockSpans.length);
for (const span of mockSpans) {
const spanElement = wrapper.find(`[data-test="trace-tree-span-container-${span.spanId}"]`);
expect(spanElement.exists()).toBe(true);
}
});
it("should render span operation names", () => {
for (const span of mockSpans) {
const operationNameElement = wrapper.find(
`[data-test="trace-tree-span-operation-name-${span.spanId}"]`,
);
expect(operationNameElement.exists()).toBe(true);
expect(operationNameElement.text()).toBe(span.operationName);
}
});
it("should render service names", () => {
const serviceNameElements = wrapper.findAll('[data-test^="trace-tree-span-service-name-"]');
expect(serviceNameElements.length).toBe(mockSpans.length);
expect(serviceNameElements[0].text()).toBe(mockSpans[0].serviceName);
expect(serviceNameElements[1].text()).toBe(mockSpans[1].serviceName);
});
it("should render error icon for error spans", () => {
const errorIcon = wrapper.find('[data-test="trace-tree-span-error-icon-6702b0494b2b6e57"]');
expect(errorIcon.exists()).toBe(true);
});
it("should not render error icon for non-error spans", () => {
const errorIcon = wrapper.find('[data-test="trace-tree-span-error-icon-d9603ec7f76eb499"]');
expect(errorIcon.exists()).toBe(false);
});
it("should render collapse button for spans with children", () => {
const collapseBtn = wrapper.find(
'[data-test="trace-tree-span-badge-collapse-btn-d9603ec7f76eb499"]',
);
expect(collapseBtn.exists()).toBe(true);
});
it("should not render collapse button for spans without children", () => {
const collapseBtn = wrapper.find(
'[data-test="trace-tree-span-badge-collapse-btn-6702b0494b2b6e57"]',
);
expect(collapseBtn.exists()).toBe(false);
});
it("should render view logs button", () => {
const viewLogsBtn = wrapper.find(
'[data-test="trace-tree-span-view-logs-btn-d9603ec7f76eb499"]',
);
expect(viewLogsBtn.exists()).toBe(true);
});
describe("Span selection", () => {
it("should emit selectSpan when span is clicked", async () => {
const selectBtn = wrapper.find(
'[data-test="trace-tree-span-operation-name-container-d9603ec7f76eb499"]',
);
expect(selectBtn.exists()).toBe(true);
await selectBtn.trigger("click");
expect(wrapper.emitted("selectSpan")).toBeTruthy();
expect(wrapper.emitted("selectSpan")[0]).toEqual(["d9603ec7f76eb499"]);
});
it("should emit selectSpan with correct span ID", async () => {
const selectBtn = wrapper.find(
'[data-test="trace-tree-span-operation-name-container-6702b0494b2b6e57"]',
);
expect(selectBtn.exists()).toBe(true);
await selectBtn.trigger("click");
expect(wrapper.emitted("selectSpan")).toBeTruthy();
expect(wrapper.emitted("selectSpan")[0]).toEqual(["6702b0494b2b6e57"]);
});
});
describe("Span collapse functionality", () => {
it("should emit toggleCollapse when collapse button is clicked", async () => {
const collapseBtn = wrapper.find(
'[data-test="trace-tree-span-badge-collapse-btn-d9603ec7f76eb499"]',
);
expect(collapseBtn.exists()).toBe(true);
await collapseBtn.trigger("click");
expect(wrapper.emitted("toggleCollapse")).toBeTruthy();
expect(wrapper.emitted("toggleCollapse")[0]).toEqual(["d9603ec7f76eb499"]);
});
it("should apply correct collapse icon rotation", async () => {
await wrapper.setProps({
collapseMapping: { d9603ec7f76eb499: true },
});
const collapseBtn = wrapper.find(
'[data-test="trace-tree-span-badge-collapse-btn-d9603ec7f76eb499"]',
);
expect(collapseBtn.exists()).toBe(true);
});
it("should apply correct expand icon rotation", async () => {
await wrapper.setProps({
collapseMapping: { d9603ec7f76eb499: false },
});
const collapseBtn = wrapper.find(
'[data-test="trace-tree-span-badge-collapse-btn-d9603ec7f76eb499"]',
);
expect(collapseBtn.exists()).toBe(true);
});
});
describe("View logs functionality", () => {
it("should emit view-logs when view logs button is clicked", async () => {
const viewLogsBtn = wrapper.find(
'[data-test="trace-tree-span-view-logs-btn-d9603ec7f76eb499"]',
);
expect(viewLogsBtn.exists()).toBe(true);
await viewLogsBtn.trigger("click");
// The component should call viewSpanLogs function which uses useTraces composable
// to navigate to logs page with span-specific query parameters
expect(viewLogsBtn.exists()).toBe(true);
});
});
describe("Search functionality", () => {
it("should highlight spans that match search query", async () => {
await wrapper.setProps({
searchQuery: "scheduler",
});
await flushPromises();
const highlightedSpans = wrapper
.findAll('[data-test^="trace-tree-span-service-name-"]')
.filter((el) => el.classes().includes("bg-table-highlight-bg"));
expect(highlightedSpans.length).toBeGreaterThan(0);
});
it("should highlight current match", async () => {
await wrapper.setProps({
searchQuery: "scheduler",
});
await flushPromises();
const currentMatch = wrapper
.findAll('[data-test^="trace-tree-span-service-name-"]')
.filter((el) => el.classes().includes("text-status-error-text"));
expect(currentMatch.length).toBeGreaterThan(0);
});
it("should not highlight when no search query", async () => {
await wrapper.setProps({
searchQuery: "",
});
await flushPromises();
const highlightedSpans = wrapper
.findAll('[data-test^="trace-tree-span-service-name-"]')
.filter((el) => el.classes().includes("bg-yellow-300"));
expect(highlightedSpans.length).toBe(0);
});
it("should find matches in service name", async () => {
await wrapper.setProps({
searchQuery: "scheduler",
});
await flushPromises();
const searchResults = wrapper.vm.searchResults;
expect(searchResults.length).toBeGreaterThan(0);
});
it("should find matches in operation name", async () => {
await wrapper.setProps({
searchQuery: "evaluate_scheduled",
});
await flushPromises();
const searchResults = wrapper.vm.searchResults;
expect(searchResults.length).toBeGreaterThan(0);
});
it("should find matches in span ID", async () => {
await wrapper.setProps({
searchQuery: "d9603ec7f76eb499",
});
await flushPromises();
const searchResults = wrapper.vm.searchResults;
expect(searchResults.length).toBeGreaterThan(0);
});
it("should handle case-insensitive search", async () => {
await wrapper.setProps({
searchQuery: "SCHEDULER",
});
await flushPromises();
const searchResults = wrapper.vm.searchResults;
expect(searchResults.length).toBeGreaterThan(0);
});
it("should emit search-result with correct count", async () => {
await wrapper.setProps({
searchQuery: "scheduler",
});
await flushPromises();
expect(wrapper.emitted("search-result")).toBeTruthy();
expect(wrapper.emitted("search-result")[0]).toEqual([2]);
});
it("should emit update-current-index when current index changes", async () => {
await wrapper.setProps({
searchQuery: "scheduler",
});
await flushPromises();
expect(wrapper.emitted("update-current-index")).toBeTruthy();
expect(wrapper.emitted("update-current-index")[0]).toEqual([0]);
});
describe("isHighlighted function", () => {
beforeEach(async () => {
await wrapper.setProps({
searchQuery: "scheduler",
});
await flushPromises();
});
it("should return true for array path that matches search results", () => {
wrapper.vm.searchResults = [
["service_name", "scheduler"],
["operation_name", "evaluate_scheduled"],
];
const result = wrapper.vm.isHighlighted(["service_name", "scheduler"]);
expect(result).toBe(true);
});
it("should return false for array path that doesn't match search results", () => {
wrapper.vm.searchResults = [["service_name", "scheduler"]];
const result = wrapper.vm.isHighlighted(["operation_name", "process"]);
expect(result).toBe(false);
});
it("should return true for single value that matches search results", () => {
wrapper.vm.searchResults = ["d9603ec7f76eb499", "6702b0494b2b6e57"];
const result = wrapper.vm.isHighlighted("d9603ec7f76eb499");
expect(result).toBe(true);
});
it("should return false for single value that doesn't match search results", () => {
wrapper.vm.searchResults = ["d9603ec7f76eb499"];
const result = wrapper.vm.isHighlighted("6702b0494b2b6e57");
expect(result).toBe(false);
});
it("should handle empty search results", () => {
wrapper.vm.searchResults = [];
const result = wrapper.vm.isHighlighted("d9603ec7f76eb499");
expect(result).toBe(false);
});
});
describe("scrollToMatch function", () => {
beforeEach(async () => {
await wrapper.setProps({
searchQuery: "scheduler",
});
await flushPromises();
});
it("should call virtualizer scrollToIndex when match exists", async () => {
// scrollToMatch delegates to scrollToSpan → rowVirtualizer.scrollToIndex.
// We verify the component does not throw and searchResults is non-empty.
wrapper.vm.searchResults = ["d9603ec7f76eb499"];
wrapper.vm.currentIndex = 0;
// Should not throw — span index 0 corresponds to mockSpans[0].spanId
expect(() => wrapper.vm.scrollToMatch()).not.toThrow();
});
it("should not throw when no search results", () => {
wrapper.vm.searchResults = [];
wrapper.vm.currentIndex = null;
expect(() => wrapper.vm.scrollToMatch()).not.toThrow();
});
it("should not throw when span ID is not found in spans array", () => {
wrapper.vm.searchResults = ["nonexistent-span-id"];
wrapper.vm.currentIndex = 0;
// scrollToSpan returns early when spanIndex === -1
expect(() => wrapper.vm.scrollToMatch()).not.toThrow();
});
});
describe("cancelScroll function", () => {
const validSpanId = "d9603ec7f76eb499";
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("should expose cancelScroll", () => {
expect(wrapper.vm.cancelScroll).toBeTypeOf("function");
});
it("should clear a pending scroll so scrollToIndex never fires", () => {
wrapper.vm.scrollToSpan(validSpanId, 300);
wrapper.vm.cancelScroll();
vi.advanceTimersByTime(300);
expect(mockScrollToIndex).not.toHaveBeenCalled();
});
it("should let a pending scroll fire when not cancelled", () => {
wrapper.vm.scrollToSpan(validSpanId, 300);
vi.advanceTimersByTime(300);
expect(mockScrollToIndex).toHaveBeenCalledTimes(1);
});
it("should supersede an earlier pending scroll with the latest one", () => {
wrapper.vm.scrollToSpan(validSpanId, 300);
wrapper.vm.scrollToSpan(validSpanId, 300);
vi.advanceTimersByTime(300);
// The first timeout was cleared by the second call.
expect(mockScrollToIndex).toHaveBeenCalledTimes(1);
});
it("should not throw when nothing is pending", () => {
expect(() => wrapper.vm.cancelScroll()).not.toThrow();
});
it("should drop a pending scroll on unmount", () => {
wrapper.vm.scrollToSpan(validSpanId, 300);
wrapper.unmount();
vi.advanceTimersByTime(300);
expect(mockScrollToIndex).not.toHaveBeenCalled();
// Re-mount so the suite-level afterEach unmounts a live wrapper.
wrapper = mountTraceTree();
});
});
});
describe("Navigation methods", () => {
beforeEach(async () => {
await wrapper.setProps({
searchQuery: "scheduler",
});
await flushPromises();
});
it("should have nextMatch method", () => {
expect(wrapper.vm.nextMatch).toBeDefined();
});
it("should have prevMatch method", () => {
expect(wrapper.vm.prevMatch).toBeDefined();
});
it("should navigate to next match", async () => {
const initialIndex = wrapper.vm.currentIndex;
wrapper.vm.nextMatch();
await flushPromises();
expect(wrapper.vm.currentIndex).toBe(initialIndex + 1);
});
it("should navigate to previous match", async () => {
// First go to next match
wrapper.vm.nextMatch();
await flushPromises();
const currentIndex = wrapper.vm.currentIndex;
wrapper.vm.prevMatch();
await flushPromises();
expect(wrapper.vm.currentIndex).toBe(currentIndex - 1);
});
it("should not navigate beyond bounds", async () => {
// Try to go to previous when at first match
wrapper.vm.prevMatch();
await flushPromises();
expect(wrapper.vm.currentIndex).toBe(0);
});
it("should not navigate beyond last match", async () => {
// Go to last match
wrapper.vm.nextMatch();
await flushPromises();
const lastIndex = wrapper.vm.currentIndex;
wrapper.vm.nextMatch();
await flushPromises();
expect(wrapper.vm.currentIndex).toBe(lastIndex);
});
});
// Hover uses CSS-only (:hover pseudo-class), no JS state tracking.
// jsdom does not apply CSS pseudo-class styles so these tests are skipped.
describe.skip("Hover functionality", () => {
it("should show view logs button on hover", async () => {
const operationContainer = wrapper.find(
'[data-test="trace-tree-span-operation-name-container-d9603ec7f76eb499"]',
);
await operationContainer.trigger("mouseover");
const viewLogsContainer = wrapper.find(
'[data-test="trace-tree-span-view-logs-container-d9603ec7f76eb499"]',
);
expect(viewLogsContainer.classes()).toContain("show");
});
it("should hide view logs button when not hovered", async () => {
const operationContainer = wrapper.find(
'[data-test="trace-tree-span-operation-name-container-d9603ec7f76eb499"]',
);
await operationContainer.trigger("mouseover");
await operationContainer.trigger("mouseout");
const viewLogsContainer = wrapper.find(
'[data-test="trace-tree-span-view-logs-container-d9603ec7f76eb499"]',
);
expect(viewLogsContainer.classes()).not.toContain("show");
});
});
describe("Theme support", () => {
it("should apply light theme by default", () => {
const operationContainer = wrapper.find(
'[data-test="trace-tree-span-operation-name-container-d9603ec7f76eb499"]',
);
expect(operationContainer.exists()).toBe(true);
expect(operationContainer.classes()).toContain("bg-surface-base");
});
it("should apply dark theme when store theme is dark", async () => {
const darkStore = createStore({
state: {
theme: "dark",
API_ENDPOINT: "http://localhost:8080",
zoConfig: {
timestamp_column: "@timestamp",
},
selectedOrganization: {
identifier: "test-org",
},
},
});
const darkWrapper = mountTraceTree({}, darkStore);
const operationContainer = darkWrapper.find(
'[data-test="trace-tree-span-operation-name-container-d9603ec7f76eb499"]',
);
expect(operationContainer.exists()).toBe(true);
// Token-based utility handles dark mode; class is identical in both themes.
expect(operationContainer.classes()).toContain("bg-surface-base");
darkWrapper.unmount();
});
});
describe("Span block integration", () => {
it("should render span-block stubs for each span", () => {
// span-block is stubbed; verify the component renders without error
// and the expected number of span containers is present.
const spanContainers = wrapper.findAll('[data-test^="trace-tree-span-container-"]');
expect(spanContainers.length).toBe(mockSpans.length);
});
it("should not show span-block when isSidebarOpen is true", async () => {
await wrapper.setProps({ isSidebarOpen: true });
await flushPromises();
// With isSidebarOpen=true the span-block column is hidden via v-if
const spanBlocks = wrapper.findAllComponents({ name: "span-block" });
expect(spanBlocks.length).toBe(0);
});
});
describe("Error handling", () => {
it("should handle empty spans array", async () => {
await wrapper.setProps({
spans: [],
});
const spanElements = wrapper.findAll('[data-test^="trace-tree-span-"]');
expect(spanElements.length).toBe(0);
});
it("should handle missing span data", async () => {
await wrapper.setProps({
spanMap: {},
});
// Component should not crash
expect(wrapper.exists()).toBe(true);
});
it("should handle invalid search query", async () => {
await wrapper.setProps({
searchQuery: null,
});
await flushPromises();
// Component should not crash
expect(wrapper.exists()).toBe(true);
});
});
describe("Computed properties", () => {
describe("currentSelectedValue", () => {
it("should return null when currentIndex is -1", () => {
wrapper.vm.currentIndex = -1;
wrapper.vm.searchResults = ["d9603ec7f76eb499"];
expect(wrapper.vm.currentSelectedValue).toBeNull();
});
it("should return null when searchResults is empty", () => {
wrapper.vm.currentIndex = 0;
wrapper.vm.searchResults = [];
expect(wrapper.vm.currentSelectedValue).toBeNull();
});
it("should return the current selected value when valid", () => {
wrapper.vm.currentIndex = 0;
wrapper.vm.searchResults = ["d9603ec7f76eb499", "6702b0494b2b6e57"];
expect(wrapper.vm.currentSelectedValue).toBe("d9603ec7f76eb499");
});
it("should return the correct value when currentIndex is not 0", () => {
wrapper.vm.currentIndex = 1;
wrapper.vm.searchResults = ["d9603ec7f76eb499", "6702b0494b2b6e57"];
expect(wrapper.vm.currentSelectedValue).toBe("6702b0494b2b6e57");
});
});
});
describe("findMatches function", () => {
it("should find matches in string values", () => {
const spanList = [
{
span_id: "span1",
service_name: "scheduler",
operation_name: "process",
},
{ span_id: "span2", service_name: "other", operation_name: "other" },
];
const results = wrapper.vm.findMatches(spanList, "scheduler");
expect(results).toContain("span1");
expect(results).not.toContain("span2");
});
it("should find matches in number values", () => {
const spanList = [
{ span_id: "span1", duration: 321372 },
{ span_id: "span2", duration: 100000 },
];
const results = wrapper.vm.findMatches(spanList, "321372");
expect(results).toContain("span1");
expect(results).not.toContain("span2");
});
it("should find matches in duration with us suffix", () => {
const spanList = [
{ span_id: "span1", duration: 321372 },
{ span_id: "span2", duration: 100000 },
];
const results = wrapper.vm.findMatches(spanList, "321372us");
expect(results).toContain("span1");
expect(results).not.toContain("span2");
});
it("should handle case-insensitive search", () => {
const spanList = [
{ span_id: "span1", service_name: "Scheduler" },
{ span_id: "span2", service_name: "Other" },
];
const results = wrapper.vm.findMatches(spanList, "scheduler");
expect(results).toContain("span1");
expect(results).not.toContain("span2");
});
it("should handle trimmed search query", () => {
const spanList = [
{ span_id: "span1", service_name: "scheduler" },
{ span_id: "span2", service_name: "other" },
];
const results = wrapper.vm.findMatches(spanList, " scheduler ");
expect(results).toContain("span1");
expect(results).not.toContain("span2");
});
it("should skip non-string and non-number values", () => {
const spanList = [
{
span_id: "span1",
service_name: "scheduler",
metadata: { key: "value" },
},
{ span_id: "span2", service_name: "other" },
];
const results = wrapper.vm.findMatches(spanList, "scheduler");
expect(results).toContain("span1");
expect(results).not.toContain("span2");
});
it("should return empty array when no matches found", () => {
const spanList = [
{ span_id: "span1", service_name: "scheduler" },
{ span_id: "span2", service_name: "other" },
];
const results = wrapper.vm.findMatches(spanList, "nonexistent");
expect(results).toEqual([]);
});
it("should handle empty search query", () => {
const spanList = [{ span_id: "span1", service_name: "scheduler" }];
const results = wrapper.vm.findMatches(spanList, "");
expect(results).toEqual([]);
});
});
describe("updateSearch function", () => {
beforeEach(() => {
vi.spyOn(wrapper.vm, "scrollToMatch");
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should update search results when search query exists", async () => {
const localSpanList = [
{ span_id: "span1", service_name: "scheduler" },
{ span_id: "span2", service_name: "other" },
];
await wrapper.setProps({
spanList: localSpanList,
searchQuery: "scheduler",
});
await flushPromises();
expect(wrapper.vm.searchResults).toContain("span1");
expect(wrapper.vm.currentIndex).toBe(0);
});
it("should clear search results when search query is empty", async () => {
await wrapper.setProps({
searchQuery: "scheduler",
});
await flushPromises();
await wrapper.setProps({
searchQuery: "",
});
await flushPromises();
expect(wrapper.vm.searchResults).toEqual([]);
expect(wrapper.vm.currentIndex).toBeNull();
});
it("should clear search results when search query is only whitespace", async () => {
wrapper.vm.searchResults = ["span1", "span2"];
wrapper.vm.currentIndex = 1;
await wrapper.setProps({
searchQuery: " ",
});
await flushPromises();
expect(wrapper.vm.searchResults).toEqual([]);
expect(wrapper.vm.currentIndex).toBeNull();
});
});
describe("Performance and rendering", () => {
it("should render large number of spans efficiently", async () => {
const largeSpans = Array.from({ length: 100 }, (_, i) => ({
...mockSpans[0],
spanId: `span-${i}`,
index: i,
}));
await wrapper.setProps({
spans: largeSpans,
});
await flushPromises();
const spanElements = wrapper.findAll('[data-test^="trace-tree-span-container-"]');
expect(spanElements.length).toBe(100);
});
it("should update efficiently when props change", async () => {
await wrapper.setProps({
spans: [mockSpans[0]],
});
await flushPromises();
const updatedSpans = wrapper.findAll('[data-test^="trace-tree-span-container-"]');
expect(updatedSpans.length).toBe(1);
});
});
// ─── New tests covering functionality added after Oct 06 2025 ───────────────
describe("isSidebarOpen prop", () => {
it("should hide span-block area when isSidebarOpen is true", async () => {
await wrapper.setProps({ isSidebarOpen: true });
await flushPromises();
// When isSidebarOpen is true the right-hand SpanBlock column is hidden via v-if
const spanBlocks = wrapper.findAllComponents({ name: "span-block" });
expect(spanBlocks.length).toBe(0);
});
it("should show span-block area when isSidebarOpen is false", async () => {
await wrapper.setProps({ isSidebarOpen: false });
await flushPromises();
const spanBlocks = wrapper.findAllComponents({ name: "span-block" });
expect(spanBlocks.length).toBeGreaterThan(0);
});
it("should apply leftWidth as inline width style when isSidebarOpen is true", async () => {
await wrapper.setProps({ isSidebarOpen: true, leftWidth: 400 });
await flushPromises();
// The root div should have width:400px when sidebar is open
const root = wrapper.element as HTMLElement;
expect(root.style.width).toBe("400px");
});
it("should not apply inline width style when isSidebarOpen is false", async () => {
await wrapper.setProps({ isSidebarOpen: false, leftWidth: 400 });
await flushPromises();
const root = wrapper.element as HTMLElement;
expect(root.style.width).toBe("");
});
});
describe("selectedSpanId prop — span-row-selected highlight", () => {
it("should add span-row-selected class to the matching span row", async () => {
await wrapper.setProps({ selectedSpanId: "d9603ec7f76eb499" });
await flushPromises();
const row = wrapper.find('[data-test="trace-tree-span-container-d9603ec7f76eb499"]');
expect(row.exists()).toBe(true);
expect(row.classes()).toContain("span-row-selected");
});
it("should not add span-row-selected to non-matching rows", async () => {
await wrapper.setProps({ selectedSpanId: "d9603ec7f76eb499" });
await flushPromises();
const row = wrapper.find('[data-test="trace-tree-span-container-6702b0494b2b6e57"]');
expect(row.exists()).toBe(true);
expect(row.classes()).not.toContain("span-row-selected");
});
it("should remove span-row-selected when selectedSpanId is cleared", async () => {
await wrapper.setProps({ selectedSpanId: "d9603ec7f76eb499" });
await flushPromises();
await wrapper.setProps({ selectedSpanId: "" });
await flushPromises();
const row = wrapper.find('[data-test="trace-tree-span-container-d9603ec7f76eb499"]');
expect(row.exists()).toBe(true);
expect(row.classes()).not.toContain("span-row-selected");
});
});
describe("LLM trace functionality", () => {
const llmSpan = {
spanId: "llm-span-001",
operationName: "openai.chat",
serviceName: "llm-service",
resolvedIdentity: "llm-service",
spanStatus: "UNSET",
spanKind: "Client",
parentId: null,
hasChildSpans: false,
genAiUsage: { total: 1500, prompt: 1000, completion: 500 },
genAiCost: { total: 0.03 },
style: {
color: "#ab63fa",
backgroundColor: "#ab63fa33",
top: "0px",
left: "0px",
},
depth: 0,
index: 0,
};
beforeEach(async () => {
await wrapper.setProps({ spans: [llmSpan] });
await flushPromises();
});
it("should expose isLLMTrace utility", () => {
expect(wrapper.vm.isLLMTrace).toBeDefined();
expect(typeof wrapper.vm.isLLMTrace).toBe("function");
});
it("should expose formatTokens utility", () => {
expect(wrapper.vm.formatTokens).toBeDefined();
expect(typeof wrapper.vm.formatTokens).toBe("function");
});
it("should expose formatCost utility", () => {
expect(wrapper.vm.formatCost).toBeDefined();
expect(typeof wrapper.vm.formatCost).toBe("function");
});
it("isLLMTrace should return false for a regular span", () => {
expect(wrapper.vm.isLLMTrace(mockSpans[0])).toBe(false);
});
});
describe("getChildCount helper", () => {
it("should return 0 for a span with no spans array", () => {
const span = { spanId: "x", hasChildSpans: false };
expect(wrapper.vm.getChildCount(span)).toBe(0);
});
it("should return 0 for a span with null spans", () => {
const span = { spanId: "x", hasChildSpans: true, spans: null };
expect(wrapper.vm.getChildCount(span)).toBe(0);
});
it("should return correct count for a span with children", () => {
const span = {
spanId: "x",
hasChildSpans: true,
spans: [{ spanId: "c1" }, { spanId: "c2" }, { spanId: "c3" }],
};
expect(wrapper.vm.getChildCount(span)).toBe(3);
});
});
// getDirectChildren does not exist in the current virtualizer-based implementation.
describe.skip("getDirectChildren helper", () => {
it("should return empty array for span with no spans field", () => {
const span = { spanId: "x" };
expect(wrapper.vm.getDirectChildren(span)).toEqual([]);
});
it("should return the spans array for a parent span", () => {
const children = [{ spanId: "c1" }, { spanId: "c2" }];
const span = { spanId: "x", hasChildSpans: true, spans: children };
expect(wrapper.vm.getDirectChildren(span)).toEqual(children);
});
});
// getChildrenHeight does not exist in the current virtualizer-based implementation.
describe.skip("getChildrenHeight helper", () => {
it("should return 0 when span has no spans array", () => {
const span = { spanId: "x", hasChildSpans: false };
expect(wrapper.vm.getChildrenHeight(span)).toBe(0);
});
it("should return 0 when span is not collapsed in collapseMapping", async () => {
await wrapper.setProps({ collapseMapping: { "parent-span": false } });
const span = {
spanId: "parent-span",
hasChildSpans: true,
spans: [{ spanId: "c1", hasChildSpans: false, spans: [] }],
};
expect(wrapper.vm.getChildrenHeight(span)).toBe(0);
});
it("should count visible children when span is expanded in collapseMapping", async () => {
await wrapper.setProps({
collapseMapping: { "parent-span": true },
});
const span = {
spanId: "parent-span",
hasChildSpans: true,
spans: [
{ spanId: "c1", hasChildSpans: false, spans: [] },
{ spanId: "c2", hasChildSpans: false, spans: [] },
],
};
expect(wrapper.vm.getChildrenHeight(span)).toBe(2);
});
});
// connectorPaths, setBadgeRef, and calculateConnectors do not exist in the
// current virtualizer-based implementation (CSS connectors only).
describe.skip("connectorPaths and setBadgeRef", () => {
it("should expose connectorPaths as a reactive ref", () => {
expect(wrapper.vm.connectorPaths).toBeDefined();
expect(typeof wrapper.vm.connectorPaths).toBe("object");
});
it("setBadgeRef should store element reference for a given spanId", () => {
const mockEl = document.createElement("div");
wrapper.vm.setBadgeRef("test-span-id", mockEl);
expect(wrapper.exists()).toBe(true);
});
it("setBadgeRef should ignore null elements", () => {
expect(() => wrapper.vm.setBadgeRef("test-span-id", null)).not.toThrow();
});
it("calculateConnectors should be callable without throwing", () => {
expect(() => wrapper.vm.calculateConnectors()).not.toThrow();
});
});
describe.skip("hover-span / unhover-span emits", () => {
it("should emit hoverSpan when onHoverSpan is called with a spanId", () => {
wrapper.vm.onHoverSpan("d9603ec7f76eb499");
expect(wrapper.emitted("hoverSpan")).toBeTruthy();
expect(wrapper.emitted("hoverSpan")[0]).toEqual(["d9603ec7f76eb499"]);
});
it("should emit unhoverSpan when onUnhoverSpan is called", () => {
wrapper.vm.onUnhoverSpan();
expect(wrapper.emitted("unhoverSpan")).toBeTruthy();
expect(wrapper.emitted("unhoverSpan")[0]).toEqual([]);
});
});
describe("highlightedSpanId computed", () => {
it("should prefer hoveredSpanId over selectedSpanId when both are set", async () => {
await wrapper.setProps({
selectedSpanId: "d9603ec7f76eb499",
hoveredSpanId: "6702b0494b2b6e57",
});
expect(wrapper.vm.highlightedSpanId).toBe("6702b0494b2b6e57");
});
it("should fall back to selectedSpanId when hoveredSpanId is empty", async () => {
await wrapper.setProps({
selectedSpanId: "d9603ec7f76eb499",
hoveredSpanId: "",
});
expect(wrapper.vm.highlightedSpanId).toBe("d9603ec7f76eb499");
});
it("should return selectedSpanId when both are empty", async () => {
await wrapper.setProps({
selectedSpanId: "",
hoveredSpanId: "",
});
expect(wrapper.vm.highlightedSpanId).toBe("");
});
});
describe("connector segments for spans with depth > 0", () => {
const depthSpans = [
{
spanId: "parent-span",
operationName: "parent-operation",
serviceName: "parent-service",
resolvedIdentity: "parent-service",
spanStatus: "UNSET",
spanKind: "Server",
parentId: null,
hasChildSpans: false,
style: {
color: "#1ab8be",
backgroundColor: "#1ab8be33",
top: "0px",
left: "0px",
},
depth: 0,
index: 0,
},
{
spanId: "child-span",
operationName: "child-operation",
serviceName: "child-service",
resolvedIdentity: "child-service",
spanStatus: "UNSET",
spanKind: "Client",
parentId: "parent-span",
hasChildSpans: false,
style: {
color: "#b7885e",
backgroundColor: "#b7885e33",
top: "30px",
left: "30px",
},
depth: 1,
index: 1,
},
];
let depthWrapper: any;
beforeEach(() => {
depthWrapper = mountTraceTree({ spans: depthSpans });
});
afterEach(() => {
depthWrapper.unmount();
});
it("should render vertical connector segments for spans with depth > 0", () => {
const verticalSegments = depthWrapper.findAll('[data-test="vertical-segment"]');
expect(verticalSegments.length).toBeGreaterThan(0);
});
it("should render horizontal connector segments for spans with depth > 0", () => {
const horizontalSegment = depthWrapper.find('[data-test="horizontal-segment"]');
expect(horizontalSegment.exists()).toBe(true);
});
});
describe("collapse badge title tooltip", () => {
// collapseMapping[id]=true means the span is currently collapsed (children hidden)
// The title describes what clicking WILL DO (action), not the current state
it("should show 'expand' title when span is currently collapsed (collapseMapping=true)", async () => {
await wrapper.setProps({
collapseMapping: { d9603ec7f76eb499: true },
});
await flushPromises();
const badge = wrapper.find(
'[data-test="trace-tree-span-badge-collapse-btn-d9603ec7f76eb499"]',
);
expect(badge.exists()).toBe(true);
expect(badge.attributes("title")).toContain("expand");
});
it("should show 'collapse' title when span is currently expanded (collapseMapping=false)", async () => {
await wrapper.setProps({
collapseMapping: { d9603ec7f76eb499: false },
});
await flushPromises();
const badge = wrapper.find(
'[data-test="trace-tree-span-badge-collapse-btn-d9603ec7f76eb499"]',
);
expect(badge.exists()).toBe(true);
expect(badge.attributes("title")).toContain("collapse");
});
});
describe("viewSpanLogs", () => {
// A formatted tree node — what the `spans` prop holds: camelCase keys and
// microsecond timestamps. This is NOT the shape buildQueryDetails accepts.
const formattedSpan = {
spanId: "d9603ec7f76eb499",
operationName: "service:alerts:evaluate_scheduled",
serviceName: "scheduler",
resolvedIdentity: "scheduler",
spanStatus: "UNSET",
spanKind: "Client",
parentId: null,
hasChildSpans: false,
startTimeUs: 1752490492843047,
endTimeUs: 1752490493164419,
style: {
color: "#1ab8be",
backgroundColor: "#1ab8be33",
top: "0px",
left: "0px",
},
depth: 0,
index: 0,
};
// The raw API span behind it — what `spanMap` holds: snake_case keys and
// nanosecond timestamps. buildQueryDetails must receive THIS object.
const rawSpan = {
_timestamp: 1752490492843047,
start_time: 1752490492843047200,
end_time: 1752490493164419300,
duration: 321372,
span_id: "d9603ec7f76eb499",
operation_name: "service:alerts:evaluate_scheduled",
service_name: "scheduler",
span_status: "UNSET",
span_kind: 2,
parent_id: null,
};
// Real case: a span with no `span_id` is given a synthetic `spanId` by the
// formatter, so `spanMap` has no entry for it.
const orphanSpan = {
...formattedSpan,
spanId: "synthetic-span-id-0",
};
const queryDetailsStub = {
stream_name: "default",
from: 1752490492843,
to: 1752490493164,
};
const viewLogsBtnFor = (w: any, spanId: string) =>
w.find(`[data-test="trace-tree-span-view-logs-btn-${spanId}"]`);
const originalIsEnterprise = config.isEnterprise;
/** Mounts the tree with a single span and a spanMap containing only rawSpan. */
function mountWithSpan(span: Record<string, unknown>) {
return mountTraceTree({
spans: [span],
spanMap: { [rawSpan.span_id]: rawSpan },
spanList: [rawSpan],
});
}
beforeEach(() => {
mockSearchObj.data.traceDetails.selectedLogStreams = [];
mockBuildQueryDetails.mockReturnValue(queryDetailsStub);
});
afterEach(() => {
config.isEnterprise = originalIsEnterprise;
mockSearchObj.data.traceDetails.selectedLogStreams = [];
});
describe("OSS mode (config.isEnterprise !== 'true')", () => {
let ossWrapper: any;
beforeEach(() => {
config.isEnterprise = "false";
});
afterEach(() => {
ossWrapper?.unmount();
ossWrapper = null;
});
it("should warn and skip both helpers when no logs stream is selected", async () => {
mockSearchObj.data.traceDetails.selectedLogStreams = [];
ossWrapper = mountWithSpan(formattedSpan);
await viewLogsBtnFor(ossWrapper, formattedSpan.spanId).trigger("click");
expect(mockToast).toHaveBeenCalledTimes(1);
expect(mockToast).toHaveBeenCalledWith({
variant: "warning",
message: "Select Logs stream first",
});
expect(mockBuildQueryDetails).not.toHaveBeenCalled();
expect(mockNavigateToLogs).not.toHaveBeenCalled();
});
it("should not emit view-correlated-logs when no logs stream is selected", async () => {
mockSearchObj.data.traceDetails.selectedLogStreams = [];
ossWrapper = mountWithSpan(formattedSpan);
await viewLogsBtnFor(ossWrapper, formattedSpan.spanId).trigger("click");
expect(ossWrapper.emitted("view-correlated-logs")).toBeFalsy();
});
it("should warn and skip both helpers when the span is missing from spanMap", async () => {
mockSearchObj.data.traceDetails.selectedLogStreams = ["default"];
ossWrapper = mountWithSpan(orphanSpan);
await viewLogsBtnFor(ossWrapper, orphanSpan.spanId).trigger("click");
expect(mockToast).toHaveBeenCalledTimes(1);
expect(mockToast).toHaveBeenCalledWith({
variant: "warning",
message: "Could not open logs for this span.",
});
expect(mockBuildQueryDetails).not.toHaveBeenCalled();
expect(mockNavigateToLogs).not.toHaveBeenCalled();
});
it("should pass the RAW span from spanMap to buildQueryDetails, not the formatted node", async () => {
mockSearchObj.data.traceDetails.selectedLogStreams = ["default"];
ossWrapper = mountWithSpan(formattedSpan);
await viewLogsBtnFor(ossWrapper, formattedSpan.spanId).trigger("click");
expect(mockBuildQueryDetails).toHaveBeenCalledTimes(1);
const passedSpan = mockBuildQueryDetails.mock.calls[0][0];
// Raw shape: snake_case keys, nanosecond timestamps.
expect(passedSpan).toEqual(rawSpan);
expect(passedSpan.span_id).toBe(rawSpan.span_id);
expect(passedSpan.start_time).toBe(rawSpan.start_time);
expect(passedSpan.end_time).toBe(rawSpan.end_time);
// Formatted shape must be absent — passing it produced `from=NaN&to=NaN`.
expect(passedSpan.spanId).toBeUndefined();
expect(passedSpan.startTimeUs).toBeUndefined();
expect(passedSpan.endTimeUs).toBeUndefined();
expect(mockBuildQueryDetails).not.toHaveBeenCalledWith(
expect.objectContaining({ spanId: formattedSpan.spanId }),
);
});
it("should navigate with the query details returned by buildQueryDetails", async () => {
mockSearchObj.data.traceDetails.selectedLogStreams = ["default"];
ossWrapper = mountWithSpan(formattedSpan);
await viewLogsBtnFor(ossWrapper, formattedSpan.spanId).trigger("click");
expect(mockNavigateToLogs).toHaveBeenCalledTimes(1);
expect(mockNavigateToLogs).toHaveBeenCalledWith(queryDetailsStub);
expect(mockNavigateToLogs.mock.calls[0][0]).toBe(queryDetailsStub);
expect(mockToast).not.toHaveBeenCalled();
});
});
describe("Enterprise mode (config.isEnterprise === 'true')", () => {
let entWrapper: any;
beforeEach(() => {
config.isEnterprise = "true";
});
afterEach(() => {
entWrapper?.unmount();
entWrapper = null;
});
it("should emit view-correlated-logs with the original formatted span", async () => {
mockSearchObj.data.traceDetails.selectedLogStreams = ["default"];
entWrapper = mountWithSpan(formattedSpan);
await viewLogsBtnFor(entWrapper, formattedSpan.spanId).trigger("click");
expect(entWrapper.emitted("view-correlated-logs")).toBeTruthy();
expect(entWrapper.emitted("view-correlated-logs")).toHaveLength(1);
expect(entWrapper.emitted("view-correlated-logs")[0][0]).toEqual(formattedSpan);
});
it("should never call buildQueryDetails or navigateToLogs", async () => {
mockSearchObj.data.traceDetails.selectedLogStreams = ["default"];
entWrapper = mountWithSpan(formattedSpan);
await viewLogsBtnFor(entWrapper, formattedSpan.spanId).trigger("click");
expect(mockBuildQueryDetails).not.toHaveBeenCalled();
expect(mockNavigateToLogs).not.toHaveBeenCalled();
});
it("should bypass the stream guard when no logs stream is selected", async () => {
mockSearchObj.data.traceDetails.selectedLogStreams = [];
entWrapper = mountWithSpan(formattedSpan);
await viewLogsBtnFor(entWrapper, formattedSpan.spanId).trigger("click");
expect(mockToast).not.toHaveBeenCalled();
expect(entWrapper.emitted("view-correlated-logs")).toHaveLength(1);
});
it("should bypass the spanMap guard for a span missing from spanMap", async () => {
mockSearchObj.data.traceDetails.selectedLogStreams = ["default"];
entWrapper = mountWithSpan(orphanSpan);
await viewLogsBtnFor(entWrapper, orphanSpan.spanId).trigger("click");
expect(mockToast).not.toHaveBeenCalled();
expect(entWrapper.emitted("view-correlated-logs")[0][0]).toEqual(orphanSpan);
});
});
});
});