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