From b771cdc49481149bd1cb55e9a81f49cfc4c5d527 Mon Sep 17 00:00:00 2001 From: Felix Uellendall Date: Fri, 14 Nov 2025 14:43:00 +0100 Subject: [PATCH] Fix TypeError in parseStreamingLogContent for non-string data The function would fail with 'TypeError: content.split is not a function' when data was not a string but also didn't have a content property. Changes: - Add type guard to check if data is string before calling .split() - Add support for single log line objects (return as array) - Simplify logic with early returns for clearer flow - Add comprehensive test suite covering all edge cases The function now handles: 1. Data with content property (returns as-is) 2. String data (parses as newline-separated JSON) 3. Object data without content (returns wrapped in array) 4. Edge cases (undefined, null, numbers, invalid JSON) --- .../src/airflow/ui/src/utils/logs.test.ts | 108 ++++++++++++++++++ airflow-core/src/airflow/ui/src/utils/logs.ts | 15 ++- 2 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 airflow-core/src/airflow/ui/src/utils/logs.test.ts diff --git a/airflow-core/src/airflow/ui/src/utils/logs.test.ts b/airflow-core/src/airflow/ui/src/utils/logs.test.ts new file mode 100644 index 0000000000000..0308937c28bee --- /dev/null +++ b/airflow-core/src/airflow/ui/src/utils/logs.test.ts @@ -0,0 +1,108 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 { describe, it, expect } from "vitest"; + +import type { TaskInstancesLogResponse } from "openapi/requests/types.gen"; + +import { parseStreamingLogContent } from "./logs"; + +describe("parseStreamingLogContent", () => { + it("returns content when data has content property", () => { + const data: TaskInstancesLogResponse = { + content: ["log line 1", "log line 2"], + continuation_token: null, + }; + + expect(parseStreamingLogContent(data)).toEqual(["log line 1", "log line 2"]); + }); + + it("parses string data as newline-separated JSON", () => { + const data = '"log line 1"\n"log line 2"\n"log line 3"' as unknown as TaskInstancesLogResponse; + + expect(parseStreamingLogContent(data)).toEqual(["log line 1", "log line 2", "log line 3"]); + }); + + it("filters out empty lines when parsing string data", () => { + const data = '"log line 1"\n\n"log line 2"\n\n\n"log line 3"' as unknown as TaskInstancesLogResponse; + + expect(parseStreamingLogContent(data)).toEqual(["log line 1", "log line 2", "log line 3"]); + }); + + it("returns empty array for undefined data", () => { + expect(parseStreamingLogContent(undefined)).toEqual([]); + }); + + it("returns empty array for null data", () => { + // eslint-disable-next-line unicorn/no-null + const data = null as unknown as TaskInstancesLogResponse; + + expect(parseStreamingLogContent(data)).toEqual([]); + }); + + it("returns object as array when data is an object without content property", () => { + const data = { someOtherProperty: "value" } as unknown as TaskInstancesLogResponse; + + expect(parseStreamingLogContent(data)).toEqual([{ someOtherProperty: "value" }]); + }); + + it("returns empty array for number data", () => { + const data = 123 as unknown as TaskInstancesLogResponse; + + expect(parseStreamingLogContent(data)).toEqual([]); + }); + + it("returns empty array when JSON parsing fails", () => { + const data = "invalid json line\nanother invalid line" as unknown as TaskInstancesLogResponse; + + expect(parseStreamingLogContent(data)).toEqual([]); + }); + + it("handles single log line as string", () => { + const data = '"single log line"' as unknown as TaskInstancesLogResponse; + + expect(parseStreamingLogContent(data)).toEqual(["single log line"]); + }); + + it("handles empty string", () => { + const data = "" as unknown as TaskInstancesLogResponse; + + expect(parseStreamingLogContent(data)).toEqual([]); + }); + + it("handles data with empty content array", () => { + const data: TaskInstancesLogResponse = { + content: [], + continuation_token: null, + }; + + expect(parseStreamingLogContent(data)).toEqual([]); + }); + + it("handles single log line object", () => { + const data = { + level: "info", + message: "log message", + timestamp: "2024-01-01", + } as unknown as TaskInstancesLogResponse; + + expect(parseStreamingLogContent(data)).toEqual([ + { level: "info", message: "log message", timestamp: "2024-01-01" }, + ]); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/utils/logs.ts b/airflow-core/src/airflow/ui/src/utils/logs.ts index 4658bc2070be3..21f12c660b049 100644 --- a/airflow-core/src/airflow/ui/src/utils/logs.ts +++ b/airflow-core/src/airflow/ui/src/utils/logs.ts @@ -57,11 +57,13 @@ export const logLevelOptions = createListCollection<{ export const parseStreamingLogContent = ( data: TaskInstancesLogResponse | undefined, ): TaskInstancesLogResponse["content"] => { - if (!data?.content) { - const content = data as unknown as string; + if (data?.content) { + return data.content; + } + if (typeof data === "string") { try { - return content + return (data as string) .split("\n") .filter((line) => line.trim() !== "") .map((line) => JSON.parse(line) as string); @@ -70,5 +72,10 @@ export const parseStreamingLogContent = ( } } - return data.content; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (typeof data === "object" && data !== null) { + return [data] as unknown as TaskInstancesLogResponse["content"]; + } + + return []; };