Files
openobserve/web/src/plugins/logs/data-table/CellActions.spec.ts
T

313 lines
13 KiB
TypeScript

// Copyright 2026 OpenObserve Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { mount, config } from "@vue/test-utils";
import { describe, it, expect, vi, afterEach } from "vitest";
import i18n from "@/locales";
import CellActions from "./CellActions.vue";
config.global.plugins = [...(config.global.plugins ?? []), i18n];
// ── Mock Vuex ──────────────────────────────────────────────────────────────────
const mockStore = {
state: {
theme: "light",
},
};
vi.mock("vuex", () => ({
useStore: () => mockStore,
}));
// ── Global stubs ──────────────────────────────────────────────────────────────
const globalStubs = {
EqualIcon: { template: "<svg />" },
NotEqualIcon: { template: "<svg />" },
O2AIContextAddBtn: {
name: "O2AIContextAddBtn",
template: "<div @click=\"$emit('send-to-ai-chat', 'test')\" />",
emits: ["send-to-ai-chat"],
},
};
const defaultProps = {
column: { id: "status" },
row: { status: "200", message: "OK" },
selectedStreamFields: [
{ name: "status", isSchemaField: true },
{ name: "message", isSchemaField: false },
],
};
const mountComponent = (propsOverrides = {}) =>
mount(CellActions, {
global: { stubs: globalStubs },
props: { ...defaultProps, ...propsOverrides },
});
describe("CellActions", () => {
afterEach(() => {
vi.clearAllMocks();
});
// The include/exclude glyphs are custom SVGs, not registry icons. Wrapping
// them in <OIcon> (whose `name` is required) meant every rendered log cell
// logged "Missing required prop: name" — the icons only appeared via OIcon's
// slot, which exists for co-locating a tooltip, not for supplying an icon.
it("renders its icons without triggering a Vue prop warning", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
mountComponent();
const warnings = warn.mock.calls.map((c) => String(c[0] ?? ""));
expect(warnings.filter((w) => w.includes("Missing required prop"))).toEqual([]);
} finally {
warn.mockRestore();
}
});
// ── Component Initialization ─────────────────────────────────────────────────
describe("Component Initialization", () => {
it("mounts successfully", () => {
const wrapper = mountComponent();
expect(wrapper.exists()).toBe(true);
});
it("renders the actions container", () => {
const wrapper = mountComponent();
expect(wrapper.find(".field_overlay").exists()).toBe(true);
});
it("renders with correct data-test attribute based on row value", () => {
const wrapper = mountComponent();
const container = wrapper.find('[data-test="log-add-data-from-column-200"]');
expect(container.exists()).toBe(true);
});
});
// ── Layout ────────────────────────────────────────────────────────────────────
describe("layout", () => {
// OTable's hover-action toolbar supplies the surface and the positioning, so
// this component must stay flow content — an absolute/background of its own
// would paint over the log line again.
it("renders as a plain flow row with no surface or positioning of its own", () => {
const classes = mountComponent().find(".field_overlay").classes();
expect(classes).toContain("flex");
expect(classes).not.toContain("absolute");
expect(classes).not.toContain("bg-surface-base");
});
});
// ── isStreamField computed ────────────────────────────────────────────────────
describe("isStreamField computed", () => {
it("returns true when column is a schema field", () => {
const wrapper = mountComponent({
column: { id: "status" },
selectedStreamFields: [{ name: "status", isSchemaField: true }],
});
expect(wrapper.vm.isStreamField).toBe(true);
});
it("returns false when column is not a schema field", () => {
const wrapper = mountComponent({
column: { id: "message" },
selectedStreamFields: [{ name: "message", isSchemaField: false }],
});
expect(wrapper.vm.isStreamField).toBe(false);
});
it("returns false when column is not in selectedStreamFields", () => {
const wrapper = mountComponent({
column: { id: "unknown_field" },
selectedStreamFields: [{ name: "status", isSchemaField: true }],
});
expect(wrapper.vm.isStreamField).toBe(false);
});
it("returns true for _stream_name, which is in no stream schema", () => {
const wrapper = mountComponent({
column: { id: "_stream_name" },
row: { _stream_name: "app" },
selectedStreamFields: [{ name: "status", isSchemaField: true }],
});
expect(wrapper.vm.isStreamField).toBe(true);
expect(wrapper.find('[data-test="log-details-include-field-app"]').exists()).toBe(true);
expect(wrapper.find('[data-test="log-details-exclude-field-app"]').exists()).toBe(true);
});
it("returns false when selectedStreamFields is empty", () => {
const wrapper = mountComponent({
selectedStreamFields: [],
});
expect(wrapper.vm.isStreamField).toBe(false);
});
});
// ── Include/Exclude term buttons visibility ────────────────────────────────────
describe("Include/Exclude term buttons visibility", () => {
it("shows include/exclude buttons when isStreamField is true and hideSearchTermActions is false", () => {
const wrapper = mountComponent({
column: { id: "status" },
selectedStreamFields: [{ name: "status", isSchemaField: true }],
hideSearchTermActions: false,
});
const includeBtns = wrapper.findAll('[data-test^="log-details-include-field"]');
const excludeBtns = wrapper.findAll('[data-test^="log-details-exclude-field"]');
expect(includeBtns.length).toBeGreaterThan(0);
expect(excludeBtns.length).toBeGreaterThan(0);
});
it("hides include/exclude buttons when isStreamField is false", () => {
const wrapper = mountComponent({
column: { id: "message" },
selectedStreamFields: [{ name: "message", isSchemaField: false }],
});
const includeBtns = wrapper.findAll('[data-test^="log-details-include-field"]');
const excludeBtns = wrapper.findAll('[data-test^="log-details-exclude-field"]');
expect(includeBtns.length).toBe(0);
expect(excludeBtns.length).toBe(0);
});
it("hides include/exclude buttons when hideSearchTermActions is true even for schema fields", () => {
const wrapper = mountComponent({
column: { id: "status" },
selectedStreamFields: [{ name: "status", isSchemaField: true }],
hideSearchTermActions: true,
});
const includeBtns = wrapper.findAll('[data-test^="log-details-include-field"]');
const excludeBtns = wrapper.findAll('[data-test^="log-details-exclude-field"]');
expect(includeBtns.length).toBe(0);
expect(excludeBtns.length).toBe(0);
});
});
// ── copyLogToClipboard ─────────────────────────────────────────────────────────
describe("copyLogToClipboard", () => {
it("should emit 'copy' with the cell value", () => {
const wrapper = mountComponent();
wrapper.vm.copyLogToClipboard("200");
const emitted = wrapper.emitted("copy");
expect(emitted).toBeTruthy();
expect(emitted![0]).toEqual(["200"]);
});
it("should emit 'copy' with the cell value for a different column", () => {
const wrapper = mountComponent({
column: { id: "message" },
row: { message: "Hello World", status: "200" },
});
wrapper.vm.copyLogToClipboard("Hello World");
expect(wrapper.emitted("copy")![0]).toEqual(["Hello World"]);
});
});
// ── addSearchTerm ─────────────────────────────────────────────────────────────
describe("addSearchTerm", () => {
it("emits 'addSearchTerm' with field, value, action=include", () => {
const wrapper = mountComponent();
wrapper.vm.addSearchTerm("status", "200", "include");
const emitted = wrapper.emitted("addSearchTerm");
expect(emitted).toBeTruthy();
expect(emitted![0]).toEqual(["status", "200", "include"]);
});
it("emits 'addSearchTerm' with field, value, action=exclude", () => {
const wrapper = mountComponent();
wrapper.vm.addSearchTerm("status", "200", "exclude");
expect(wrapper.emitted("addSearchTerm")![0]).toEqual(["status", "200", "exclude"]);
});
it("emits 'addSearchTerm' with numeric value", () => {
const wrapper = mountComponent();
wrapper.vm.addSearchTerm("latency_ms", 42, "include");
expect(wrapper.emitted("addSearchTerm")![0]).toEqual(["latency_ms", 42, "include"]);
});
it("emits 'addSearchTerm' with boolean value", () => {
const wrapper = mountComponent();
wrapper.vm.addSearchTerm("is_error", true, "exclude");
expect(wrapper.emitted("addSearchTerm")![0]).toEqual(["is_error", true, "exclude"]);
});
});
// ── sendToAiChat ──────────────────────────────────────────────────────────────
describe("sendToAiChat", () => {
it("emits 'sendToAiChat' with the value", () => {
const wrapper = mountComponent();
wrapper.vm.sendToAiChat(JSON.stringify("test value"));
const emitted = wrapper.emitted("sendToAiChat");
expect(emitted).toBeTruthy();
expect(emitted![0][0]).toBe('"test value"');
});
it("emits 'sendToAiChat' with JSON-stringified object", () => {
const wrapper = mountComponent();
const obj = { key: "value" };
wrapper.vm.sendToAiChat(JSON.stringify(obj));
expect(wrapper.emitted("sendToAiChat")![0][0]).toBe('{"key":"value"}');
});
});
// ── Props defaults ────────────────────────────────────────────────────────────
describe("value prop", () => {
// Logs' `source` column is JSON.stringify(row): nothing sits under
// row["source"], so the caller hands the rendered value in.
it("acts on the passed value when the row has nothing under column.id", async () => {
const wrapper = mountComponent({
column: { id: "source" },
row: { status: "200", message: "OK" },
value: '{"status":"200"}',
selectedStreamFields: [],
});
await wrapper.find("button").trigger("click");
expect(wrapper.emitted("copy")?.[0]).toEqual(['{"status":"200"}']);
// The title/data-test follow the same value, so they never read "undefined".
expect(wrapper.find(".table-cell-actions").attributes("data-test")).toBe(
'log-add-data-from-column-{"status":"200"}',
);
});
it("falls back to row[column.id] when no value is passed", async () => {
const wrapper = mountComponent();
await wrapper.find("button").trigger("click");
expect(wrapper.emitted("copy")?.[0]).toEqual(["200"]);
});
});
describe("Props defaults", () => {
it("defaults hideSearchTermActions to false", () => {
const wrapper = mountComponent();
expect(wrapper.props("hideSearchTermActions")).toBe(false);
});
it("defaults selectedStreamFields to empty array when not provided", () => {
const wrapper = mountComponent({ selectedStreamFields: [] });
expect(wrapper.props("selectedStreamFields")).toEqual([]);
});
});
// ── O2AIContextAddBtn interaction ──────────────────────────────────────────────
describe("O2AIContextAddBtn interaction", () => {
it("renders O2AIContextAddBtn", () => {
const wrapper = mountComponent();
// O2AIContextAddBtn is always rendered
const btn = wrapper.findComponent({ name: "O2AIContextAddBtn" });
expect(btn.exists()).toBe(true);
});
});
});