mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
## 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>
1607 lines
50 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|
|
});
|