@@ -40,11 +40,11 @@ test("streams, filters, correlates, and redacts the sample locally", async ({
|
||||
).toBeVisible();
|
||||
await page.getByLabel("Literal search").fill("timeout");
|
||||
await expect(page.getByText(/1 of 4 retained records match/u)).toBeVisible();
|
||||
await page.getByRole("tab", { name: "Correlation" }).click();
|
||||
await page.getByRole("button", { name: "Correlation" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Hourly timeline" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("tab", { name: "Redaction" }).click();
|
||||
await page.getByRole("button", { name: "Redaction" }).click();
|
||||
await expect(page.getByLabel("Redacted preview")).not.toContainText(
|
||||
"ada@example.test",
|
||||
);
|
||||
@@ -77,7 +77,7 @@ test("serves release identity and hardened headers", async ({ request }) => {
|
||||
const manifest = await request.get("/deep/nested/log/toolbox-app.json");
|
||||
await expect(manifest.json()).resolves.toMatchObject({
|
||||
id: "de.add-ideas.log-tools",
|
||||
version: "0.1.0",
|
||||
version: "0.2.0",
|
||||
entry: "./",
|
||||
privacy: { processing: "local", telemetry: false },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("keeps the primary workspace inside a narrow viewport", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/deep/nested/log/");
|
||||
await expect(page.locator("main").first()).toBeVisible();
|
||||
await expect(
|
||||
page.locator("main .loading, main .workbench-loading"),
|
||||
).toHaveCount(0);
|
||||
|
||||
const widths = await page.evaluate(() => ({
|
||||
content: document.documentElement.scrollWidth,
|
||||
viewport: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(widths.viewport).toBeLessThanOrEqual(430);
|
||||
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { App } from "../../src/App";
|
||||
|
||||
class StreamingTestBlob {
|
||||
@@ -25,6 +25,10 @@ class StreamingTestBlob {
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Log Tools", () => {
|
||||
it("streams the sample and exposes correlation and redaction views", async () => {
|
||||
vi.stubGlobal("Blob", StreamingTestBlob as unknown as typeof Blob);
|
||||
@@ -52,7 +56,7 @@ describe("Log Tools", () => {
|
||||
expect(
|
||||
screen.getByText("JSON Lines", { selector: ".metrics strong" }),
|
||||
).toBeVisible();
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Redaction" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Redaction" }));
|
||||
expect(screen.getByText("Deterministic recipes")).toBeVisible();
|
||||
expect(screen.getByLabelText("Redacted preview")).not.toHaveTextContent(
|
||||
"ada@example.test",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ScanCancelledError,
|
||||
analyzeLogSources,
|
||||
detectLogFormat,
|
||||
exportRecords,
|
||||
exportLogBlob,
|
||||
exportOtlpJson,
|
||||
filterRecords,
|
||||
parseLogLine,
|
||||
redactRecords,
|
||||
@@ -34,6 +37,17 @@ function streamingBlob(source: string, chunkSize = 7): Blob {
|
||||
} as unknown as Blob;
|
||||
}
|
||||
|
||||
async function readBlobText(blob: Blob): Promise<string> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => resolve(String(reader.result ?? "")));
|
||||
reader.addEventListener("error", () =>
|
||||
reject(reader.error ?? new Error("Could not read test Blob.")),
|
||||
);
|
||||
reader.readAsText(blob);
|
||||
});
|
||||
}
|
||||
|
||||
describe("incremental scanning", () => {
|
||||
it("uses Blob.stream and incremental TextDecoder across UTF-8 chunk boundaries", async () => {
|
||||
const source = [
|
||||
@@ -85,6 +99,29 @@ describe("incremental scanning", () => {
|
||||
expect(report.timeline.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("assembles Java, .NET, and Python continuation lines with provenance", async () => {
|
||||
const source = [
|
||||
"2026-09-01T10:00:00Z ERROR request failed",
|
||||
" at service.Handler.run(Handler.java:42)",
|
||||
"Caused by: java.io.IOException: disk",
|
||||
"2026-09-01T10:00:01Z INFO recovered",
|
||||
"Traceback (most recent call last):",
|
||||
' File "worker.py", line 4, in run',
|
||||
" raise RuntimeError()",
|
||||
"RuntimeError",
|
||||
].join("\n");
|
||||
const report = await scanLogBlob(streamingBlob(source), {
|
||||
format: "plain",
|
||||
multiline: "auto",
|
||||
});
|
||||
expect(report.totalLines).toBe(8);
|
||||
expect(report.preview[0]).toMatchObject({ line: 1, lineEnd: 3 });
|
||||
expect(report.preview[0]?.message).toContain("Caused by");
|
||||
expect(report.preview[1]).toMatchObject({ line: 4, lineEnd: 7 });
|
||||
expect(report.preview[1]?.message).toContain("worker.py");
|
||||
expect(report.preview[2]).toMatchObject({ line: 8 });
|
||||
});
|
||||
|
||||
it("supports cancellation without returning a partial replacement report", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
@@ -259,4 +296,135 @@ describe("filter, redact, and export", () => {
|
||||
).toMatchObject({ line: 1, parser: "jsonl" });
|
||||
expect(exportRecords(records, "text").value).toContain("ada@example.test");
|
||||
});
|
||||
|
||||
it("performs a complete filtered and redacted second streaming pass", async () => {
|
||||
const source = Array.from({ length: 250 }, (_unused, index) =>
|
||||
JSON.stringify({
|
||||
level: index % 2 ? "info" : "error",
|
||||
service: "api",
|
||||
message: `row ${index} from ada@example.test`,
|
||||
}),
|
||||
).join("\n");
|
||||
const progress: number[] = [];
|
||||
const result = await exportLogBlob(
|
||||
streamingBlob(source, 31),
|
||||
{
|
||||
format: "ndjson",
|
||||
inputFormat: "jsonl",
|
||||
filters: { level: "error" },
|
||||
redaction: options,
|
||||
},
|
||||
(value) => progress.push(value.bytesRead),
|
||||
);
|
||||
expect(result.rows).toBe(125);
|
||||
expect(result.physicalLines).toBe(250);
|
||||
expect(result.blob).toBeDefined();
|
||||
const output = await readBlobText(result.blob!);
|
||||
expect(output).not.toContain("ada@example.test");
|
||||
expect(output.trim().split("\n")).toHaveLength(125);
|
||||
expect(progress.at(-1)).toBe(source.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multi-file temporal and trace analysis", () => {
|
||||
const traceId = "0123456789abcdef0123456789abcdef";
|
||||
const rootSpan = "0123456789abcdef";
|
||||
const childSpan = "fedcba9876543210";
|
||||
const apiRecords = [
|
||||
parseLogLine(
|
||||
JSON.stringify({
|
||||
timestamp: "2026-09-01T10:00:00.000Z",
|
||||
level: "info",
|
||||
service: "api",
|
||||
message: "start",
|
||||
trace_id: traceId,
|
||||
span_id: rootSpan,
|
||||
}),
|
||||
1,
|
||||
"jsonl",
|
||||
),
|
||||
parseLogLine(
|
||||
JSON.stringify({
|
||||
timestamp: "2026-09-01T09:59:59.900Z",
|
||||
level: "error",
|
||||
service: "api",
|
||||
message: "late arrival",
|
||||
trace_id: traceId,
|
||||
span_id: rootSpan,
|
||||
}),
|
||||
2,
|
||||
"jsonl",
|
||||
),
|
||||
];
|
||||
const workerRecords = [
|
||||
parseLogLine(
|
||||
JSON.stringify({
|
||||
timestamp: "2026-09-01T10:00:00.250Z",
|
||||
level: "info",
|
||||
service: "worker",
|
||||
message: "child",
|
||||
traceId,
|
||||
spanId: childSpan,
|
||||
parentSpanId: rootSpan,
|
||||
}),
|
||||
1,
|
||||
"jsonl",
|
||||
),
|
||||
];
|
||||
|
||||
it("merges time order and correlates trace/span evidence across files", () => {
|
||||
const analysis = analyzeLogSources([
|
||||
{ filename: "api.log", records: apiRecords },
|
||||
{ filename: "worker.log", records: workerRecords },
|
||||
]);
|
||||
expect(analysis).toMatchObject({
|
||||
files: 2,
|
||||
inputRecords: 3,
|
||||
undatedEvents: 0,
|
||||
});
|
||||
expect(analysis.events[0]).toMatchObject({
|
||||
filename: "api.log",
|
||||
line: 2,
|
||||
traceId,
|
||||
});
|
||||
expect(analysis.groups[0]).toMatchObject({
|
||||
kind: "trace",
|
||||
id: traceId,
|
||||
events: 3,
|
||||
errors: 1,
|
||||
spanCount: 2,
|
||||
unresolvedParentSpanIds: [],
|
||||
repeatedSpanIds: [rootSpan],
|
||||
});
|
||||
expect(
|
||||
analysis.sourceTiming.find((item) => item.filename === "api.log")
|
||||
?.backwardJumps,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("maps retained records to bounded OTLP/JSON without inventing timestamps", () => {
|
||||
const value = exportOtlpJson([
|
||||
{ filename: "api.log", records: apiRecords },
|
||||
{
|
||||
filename: "undated.log",
|
||||
records: [parseLogLine("INFO no clock", 1, "plain")],
|
||||
},
|
||||
]);
|
||||
const parsed = JSON.parse(value) as {
|
||||
resourceLogs: Array<{
|
||||
scopeLogs: Array<{
|
||||
logRecords: Array<Record<string, unknown>>;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
const first = parsed.resourceLogs[0]!.scopeLogs[0]!.logRecords[0]!;
|
||||
expect(first).toMatchObject({
|
||||
traceId,
|
||||
spanId: rootSpan,
|
||||
severityNumber: 9,
|
||||
timeUnixNano: "1788256800000000000",
|
||||
});
|
||||
const undated = parsed.resourceLogs[1]!.scopeLogs[0]!.logRecords[0]!;
|
||||
expect(undated).not.toHaveProperty("timeUnixNano");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user