Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,33 @@ class FakeKubeObjectStore extends KubeObjectStore<KubeObject> {
}
}

// Unlike FakeKubeObjectStore above, this exercises the real `loadItems()` --
// the method that owns the `onLoadFailure` branches -- against a fake
// `api.list()`, instead of bypassing it entirely.
class RealLoadItemsKubeObjectStore extends KubeObjectStore<KubeObject> {
constructor(api: Partial<KubeApi<KubeObject>>, isLoadingAll: (namespaces: string[]) => boolean = () => true) {
super(
{
context: {
allNamespaces: [],
contextNamespaces: [],
hasSelectedAll: false,
isGlobalWatchEnabled: () => true,
isLoadingAll,
},
logger: {
debug: noop,
error: noop,
info: noop,
silly: noop,
warn: noop,
},
},
api as KubeApi<KubeObject>,
);
}
}

describe("KubeObjectStore", () => {
it("should remove an object from the list of items after it is not returned from listing the same namespace again", async () => {
const loadItems = vi.fn();
Expand Down Expand Up @@ -282,4 +309,50 @@ describe("KubeObjectStore", () => {

warnSpy.mockRestore();
});

it("does not report an aborted cluster-scoped/all-namespaces load through onLoadFailure", async () => {
const onLoadFailure = vi.fn();
const list = vi.fn().mockRejectedValueOnce(new DOMException("The operation was aborted.", "AbortError"));
const store = new RealLoadItemsKubeObjectStore({ isNamespaced: false, list });

const result = await store.loadAll({ onLoadFailure });

expect(result).toBeUndefined();
expect(onLoadFailure).not.toHaveBeenCalled();
expect(store.failedLoading).toBe(false);
});

it("still reports a genuine cluster-scoped/all-namespaces load failure through onLoadFailure", async () => {
const onLoadFailure = vi.fn();
const list = vi.fn().mockRejectedValueOnce(new Error("boom"));
const store = new RealLoadItemsKubeObjectStore({ isNamespaced: false, list });

await store.loadAll({ onLoadFailure });

expect(onLoadFailure).toHaveBeenCalledTimes(1);
expect(onLoadFailure.mock.calls[0][0].message).toContain("Failed to load");
});

it("does not report an aborted namespaced load through onLoadFailure", async () => {
const onLoadFailure = vi.fn();
const list = vi.fn().mockRejectedValueOnce(new DOMException("The operation was aborted.", "AbortError"));
const store = new RealLoadItemsKubeObjectStore({ isNamespaced: true, list }, () => false);

const result = await store.loadAll({ namespaces: ["some-namespace"], onLoadFailure });

expect(result).toBeUndefined();
expect(onLoadFailure).not.toHaveBeenCalled();
expect(store.failedLoading).toBe(false);
});

it("still reports a genuine namespaced load failure through onLoadFailure", async () => {
const onLoadFailure = vi.fn();
const list = vi.fn().mockRejectedValueOnce(new Error("boom"));
const store = new RealLoadItemsKubeObjectStore({ isNamespaced: true, list }, () => false);

await store.loadAll({ namespaces: ["some-namespace"], onLoadFailure });

expect(onLoadFailure).toHaveBeenCalledTimes(1);
expect(onLoadFailure.mock.calls[0][0].message).toContain("Failed to load");
});
});
14 changes: 14 additions & 0 deletions packages/core/src/common/k8s-api/kube-object.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,14 @@ export class KubeObjectStore<
try {
return (await res) ?? [];
} catch (error) {
// An aborted request (e.g. the view unmounted before the list
// arrived) is not a real failure -- let it propagate so the
// abort-aware catch in `loadAll` can no-op on it instead of
// surfacing a spurious "Failed to load" error and blanking the list.
if (isAbortError(error)) {
throw error;
}

onLoadFailure(new Error(`Failed to load ${this.api.apiBase}`, { cause: error }));

// reset the store because we are loading all, so that nothing is displayed
Expand Down Expand Up @@ -240,6 +248,12 @@ export class KubeObjectStore<
break;

case "rejected":
// See the single-namespace branch above: an aborted request must
// propagate, not be reported through onLoadFailure.
if (isAbortError(result.reason)) {
throw result.reason;
}

if (onLoadFailure) {
onLoadFailure(new Error(`Failed to load ${this.api.apiBase}`, { cause: result.reason }));
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Copyright (c) Freelens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/

import { getDiForUnitTesting } from "../../../../getDiForUnitTesting";
import getPodsByOwnerIdInjectable from "../../../workloads-pods/get-pods-by-owner-id.injectable";
import createWorkloadLogsTabInjectable from "../create-workload-logs-tab.injectable";
import getLogTabDataInjectable from "../get-log-tab-data.injectable";
import getRandomIdForPodLogsTabInjectable from "../get-random-id-for-pod-logs-tab.injectable";
import { deploymentPod1, deploymentPod2, deploymentPod3 } from "./pod.mock";

import type { KubeObject } from "@freelensapp/kube-object";

import type { DiContainer } from "@ogre-tools/injectable";

function fakeWorkload(kind: string, name: string, uid: string): KubeObject {
return {
kind,
getName: () => name,
getId: () => uid,
} as unknown as KubeObject;
}

describe("create workload logs tab", () => {
let di: DiContainer;

beforeEach(() => {
di = getDiForUnitTesting();
di.override(getRandomIdForPodLogsTabInjectable, () => () => "test-id");
// getPodsByOwnerIdInjectable pulls in podStoreInjectable, which asserts it
// is only created in a cluster-frame environment. It is only actually
// called by the fallback (no explicit `pods`) path, but injectable()
// resolves the whole dependency graph eagerly, so every test needs a
// usable stand-in even when it never exercises that path.
di.override(getPodsByOwnerIdInjectable, () => () => []);
});

it("returns undefined when the workload has no pods", () => {
const createWorkloadLogsTab = di.inject(createWorkloadLogsTabInjectable);
const workload = fakeWorkload("StatefulSet", "empty-set", "uid-1");

expect(createWorkloadLogsTab({ workload })).toBeUndefined();
});

it("combines every pod passed in explicitly, regardless of how deep the ownership chain is", () => {
const createWorkloadLogsTab = di.inject(createWorkloadLogsTabInjectable);
const getLogTabData = di.inject(getLogTabDataInjectable);
// Deployment pods are owned by an intermediate ReplicaSet, not the
// Deployment itself, so this only works with explicitly-passed pods.
const workload = fakeWorkload("Deployment", "super-deployment", "uuid");

const tabId = createWorkloadLogsTab({
workload,
pods: [deploymentPod1, deploymentPod2, deploymentPod3],
});

expect(tabId).toBeDefined();
expect(getLogTabData(tabId!)).toMatchObject({
selectedPodId: deploymentPod1.getId(),
mergedPodIds: [deploymentPod2.getId(), deploymentPod3.getId()],
owner: { kind: "Deployment", name: "super-deployment", uid: "uuid" },
});
});

it("falls back to direct ownerReferences lookup when no pods are passed explicitly", () => {
di.override(getPodsByOwnerIdInjectable, () => (id) => (id === "uuid" ? [deploymentPod1, deploymentPod2] : []));

const createWorkloadLogsTab = di.inject(createWorkloadLogsTabInjectable);
const getLogTabData = di.inject(getLogTabDataInjectable);
const workload = fakeWorkload("ReplicaSet", "super-replicaset", "uuid");

const tabId = createWorkloadLogsTab({ workload });

expect(tabId).toBeDefined();
expect(getLogTabData(tabId!)).toMatchObject({
selectedPodId: deploymentPod1.getId(),
mergedPodIds: [deploymentPod2.getId()],
});
});

it("does not set mergedPodIds for a single-pod workload", () => {
const createWorkloadLogsTab = di.inject(createWorkloadLogsTabInjectable);
const getLogTabData = di.inject(getLogTabDataInjectable);
const workload = fakeWorkload("Deployment", "solo-deployment", "uuid");

const tabId = createWorkloadLogsTab({ workload, pods: [deploymentPod1] });

expect(getLogTabData(tabId!)).toMatchObject({
selectedPodId: deploymentPod1.getId(),
mergedPodIds: undefined,
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { renderFor } from "../../../test-utils/renderFor";
import callForLogsInjectable from "../call-for-logs.injectable";
import { LogTabViewModel } from "../logs-view-model";
import { LogResourceSelector } from "../resource-selector";
import { deploymentPod1, deploymentPod2, dockerPod } from "./pod.mock";
import { deploymentPod1, deploymentPod2, deploymentPod3, dockerPod } from "./pod.mock";
import {
createMockLogTabViewModel,
getDefaultOnePodLogTabData,
Expand Down Expand Up @@ -94,6 +94,33 @@ const getFewPodsTabData = (
});
};

const getCombinedLogsTabData = (
tabId: TabId,
userPreferencesState: UserPreferencesState,
deps: Partial<LogTabViewModelDependencies> = {},
): LogTabViewModel => {
const selectedPod = deploymentPod1;
const otherPods = [deploymentPod2, deploymentPod3];

return createMockLogTabViewModel(tabId, userPreferencesState, {
getLogTabData: () => ({
...getDefaultOnePodLogTabData({
selectedPodId: selectedPod.getId(),
selectedContainer: selectedPod.getContainers()[0].name,
namespace: selectedPod.getNs(),
}),
mergedPodIds: otherPods.map((pod) => pod.getId()),
owner: {
uid: "uuid",
kind: "Deployment",
name: "super-deployment",
},
}),
getPodById: (id) => [selectedPod, ...otherPods].find((pod) => pod.getId() === id),
...deps,
});
};

describe("<LogResourceSelector />", () => {
let render: DiRender;
let user: UserEvent;
Expand Down Expand Up @@ -202,4 +229,26 @@ describe("<LogResourceSelector />", () => {
expect(renameTab).toBeCalledWith("foobar", "Pod deploymentPod2");
});
});

describe("with combined logs of several pods", () => {
let model: LogTabViewModel;

beforeEach(() => {
model = getCombinedLogsTabData("foobar", userPreferencesState);
});

it("shows a pod count badge instead of the pod switcher dropdown", async () => {
const { container, findByTestId } = render(<LogResourceSelector model={model} />);

expect(await findByTestId("merged-pods-badge")).toHaveTextContent("3 pods");
expect(container.querySelector(".pod-selector")).not.toBeInTheDocument();
});

it("still renders the owner and container selector", async () => {
const { findByText, container } = render(<LogResourceSelector model={model} />);

expect(await findByText("super-deployment", { exact: false })).toBeInTheDocument();
expect(container.querySelector(".container-selector")).toBeInTheDocument();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Copyright (c) Freelens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/

import { getLeadingTimestamp, getPodLogColor, mergePodLogs } from "../merge-pod-logs";

describe("mergePodLogs", () => {
it("returns the lines unchanged when there is a single source pod", () => {
const lines = ["2024-01-01T00:00:00.000000000Z hello", "2024-01-01T00:00:01.000000000Z world"];

expect(mergePodLogs(new Map([["pod-a", lines]]))).toEqual(lines);
});

it("returns an empty array when given no pods", () => {
expect(mergePodLogs(new Map())).toEqual([]);
});

it("interleaves lines from several pods in chronological order", () => {
const merged = mergePodLogs(
new Map([
["pod-a", ["2024-01-01T00:00:00.000000000Z a1", "2024-01-01T00:00:02.000000000Z a2"]],
["pod-b", ["2024-01-01T00:00:01.000000000Z b1"]],
]),
);

expect(merged.map((line) => getLeadingTimestamp(line))).toEqual([
"2024-01-01T00:00:00.000000000Z",
"2024-01-01T00:00:01.000000000Z",
"2024-01-01T00:00:02.000000000Z",
]);
expect(merged[0]).toContain("[pod-a]");
expect(merged[0]).toContain("a1");
expect(merged[1]).toContain("[pod-b]");
expect(merged[1]).toContain("b1");
expect(merged[2]).toContain("[pod-a]");
expect(merged[2]).toContain("a2");
});

it("skips pods with no lines", () => {
const merged = mergePodLogs(
new Map([
["pod-a", ["2024-01-01T00:00:00.000000000Z a1"]],
["pod-b", []],
]),
);

expect(merged).toHaveLength(1);
expect(merged[0]).toContain("[pod-a]");
});

it("tags a line without a leading timestamp using only the pod name", () => {
const merged = mergePodLogs(
new Map([
["pod-a", ["not-a-timestamp continuation line"]],
["pod-b", ["2024-01-01T00:00:00.000000000Z b1"]],
]),
);

const taggedLine = merged.find((line) => line.includes("continuation line"));

expect(taggedLine).toContain("[pod-a]");
expect(taggedLine).toContain("not-a-timestamp continuation line");
});

it("assigns the same color to a pod name across calls", () => {
expect(getPodLogColor("pod-a")).toBe(getPodLogColor("pod-a"));
});

it("colors the whole line, not just the pod-name tag", () => {
const merged = mergePodLogs(
new Map([
["pod-a", ["2024-01-01T00:00:00.000000000Z hello world"]],
["pod-b", []],
]),
);
const color = getPodLogColor("pod-a");

// The color escape must stay open across the tag and the log content, and
// only reset (\x1b[0m) once, at the very end of the line -- not right
// after the tag, which would leave the log content in the default color.
expect(merged[0]).toBe(`2024-01-01T00:00:00.000000000Z \x1b[${color}m[pod-a] hello world\x1b[0m`);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export function createMockLogTabViewModel(
searchStore: new SearchStore(),
downloadLogs: vi.fn(),
downloadAllLogs: vi.fn(),
downloadAllLogsForPods: vi.fn(),
userPreferencesState,
...deps,
});
Expand Down
3 changes: 1 addition & 2 deletions packages/core/src/renderer/components/dock/logs/controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,8 @@ export interface LogControlsProps {

export const LogControls = observer(({ model }: LogControlsProps) => {
const tabData = model.logTabData.get();
const pod = model.pod.get();

if (!tabData || !pod) {
if (!tabData) {
return null;
}

Expand Down
Loading