@logtape/file
Version:
File sink and rotating file sink for LogTape
695 lines (637 loc) • 21.4 kB
text/typescript
import { getFileSink, getRotatingFileSink } from "#filesink";
import { suite } from "@alinea/suite";
import { isDeno } from "@david/which-runtime";
import type { Sink } from "@logtape/logtape";
import { assertEquals } from "@std/assert/equals";
import { assertThrows } from "@std/assert/throws";
import { join } from "@std/path/join";
import fs from "node:fs";
import { tmpdir } from "node:os";
import { debug, error, fatal, info, warning } from "../logtape/fixtures.ts";
import { type FileSinkDriver, getBaseFileSink } from "./filesink.base.ts";
const test = suite(import.meta);
function makeTempFileSync(): string {
return join(fs.mkdtempSync(join(tmpdir(), "logtape-")), "logtape.txt");
}
test("getBaseFileSink()", () => {
const path = makeTempFileSync();
let sink: Sink & Disposable;
if (isDeno) {
const driver: FileSinkDriver<Deno.FsFile> = {
openSync(path: string) {
return Deno.openSync(path, { create: true, append: true });
},
writeSync(fd, chunk) {
fd.writeSync(chunk);
},
flushSync(fd) {
fd.syncSync();
},
closeSync(fd) {
fd.close();
},
};
sink = getBaseFileSink(path, driver);
} else {
const driver: FileSinkDriver<number> = {
openSync(path: string) {
return fs.openSync(path, "a");
},
writeSync: fs.writeSync,
flushSync: fs.fsyncSync,
closeSync: fs.closeSync,
};
sink = getBaseFileSink(path, driver);
}
sink(debug);
sink(info);
sink(warning);
sink(error);
sink(fatal);
sink[Symbol.dispose]();
assertEquals(
fs.readFileSync(path, { encoding: "utf-8" }),
`\
2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [ERR] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [FTL] my-app·junk: Hello, 123 & 456!
`,
);
});
test("getBaseFileSink() with lazy option", () => {
const pathDir = fs.mkdtempSync(join(tmpdir(), "logtape-"));
const path = join(pathDir, "test.log");
let sink: Sink & Disposable;
if (isDeno) {
const driver: FileSinkDriver<Deno.FsFile> = {
openSync(path: string) {
return Deno.openSync(path, { create: true, append: true });
},
writeSync(fd, chunk) {
fd.writeSync(chunk);
},
flushSync(fd) {
fd.syncSync();
},
closeSync(fd) {
fd.close();
},
};
sink = getBaseFileSink(path, { ...driver, lazy: true });
} else {
const driver: FileSinkDriver<number> = {
openSync(path: string) {
return fs.openSync(path, "a");
},
writeSync: fs.writeSync,
flushSync: fs.fsyncSync,
closeSync: fs.closeSync,
};
sink = getBaseFileSink(path, { ...driver, lazy: true });
}
if (isDeno) {
assertThrows(
() => Deno.lstatSync(path),
Deno.errors.NotFound,
);
} else {
assertEquals(fs.existsSync(path), false);
}
sink(debug);
sink(info);
sink(warning);
sink(error);
sink(fatal);
sink[Symbol.dispose]();
assertEquals(
fs.readFileSync(path, { encoding: "utf-8" }),
`\
2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [ERR] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [FTL] my-app·junk: Hello, 123 & 456!
`,
);
});
test("getFileSink()", () => {
const path = makeTempFileSync();
const sink: Sink & Disposable = getFileSink(path);
sink(debug);
sink(info);
sink(warning);
sink(error);
sink(fatal);
sink[Symbol.dispose]();
assertEquals(
fs.readFileSync(path, { encoding: "utf-8" }),
`\
2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [ERR] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [FTL] my-app·junk: Hello, 123 & 456!
`,
);
});
test("getFileSink() with bufferSize: 0 (no buffering)", () => {
const path = makeTempFileSync();
const sink: Sink & Disposable = getFileSink(path, { bufferSize: 0 });
// Write first log entry
sink(debug);
// With no buffering, content should be immediately written to file
assertEquals(
fs.readFileSync(path, { encoding: "utf-8" }),
"2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!\n",
);
// Write second log entry
sink(info);
assertEquals(
fs.readFileSync(path, { encoding: "utf-8" }),
`\
2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!
`,
);
// Write remaining entries
sink(warning);
sink(error);
sink(fatal);
sink[Symbol.dispose]();
// Final verification
assertEquals(
fs.readFileSync(path, { encoding: "utf-8" }),
`\
2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [ERR] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [FTL] my-app·junk: Hello, 123 & 456!
`,
);
});
test("getFileSink() with small buffer size", () => {
const path = makeTempFileSync();
// Use a small buffer size (100 characters) to test buffering behavior
const sink: Sink & Disposable = getFileSink(path, { bufferSize: 100 });
// Write first log entry (about 65 characters)
sink(debug);
// Should be buffered, not yet written to file
assertEquals(fs.readFileSync(path, { encoding: "utf-8" }), "");
// Write second log entry - this should exceed buffer size and trigger flush
sink(info);
// Both entries should now be written to file
assertEquals(
fs.readFileSync(path, { encoding: "utf-8" }),
`\
2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!
`,
);
// Write third log entry - should be buffered again
sink(warning);
// Should still only have the first two entries
assertEquals(
fs.readFileSync(path, { encoding: "utf-8" }),
`\
2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!
`,
);
// Dispose should flush remaining buffer content
sink[Symbol.dispose]();
assertEquals(
fs.readFileSync(path, { encoding: "utf-8" }),
`\
2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456!
`,
);
});
test("getRotatingFileSink() with bufferSize: 0 (no buffering)", () => {
const path = makeTempFileSync();
const sink: Sink & Disposable = getRotatingFileSink(path, {
maxSize: 150,
bufferSize: 0, // No buffering - immediate writes
});
// Write first log entry - should be immediately written
sink(debug);
assertEquals(
fs.readFileSync(path, { encoding: "utf-8" }),
"2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!\n",
);
// Write second log entry - should be immediately written
sink(info);
assertEquals(
fs.readFileSync(path, { encoding: "utf-8" }),
`\
2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!
`,
);
// Write third log entry - should trigger rotation
sink(warning);
assertEquals(
fs.readFileSync(path, { encoding: "utf-8" }),
"2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456!\n",
);
// Check that rotation occurred
assertEquals(
fs.readFileSync(`${path}.1`, { encoding: "utf-8" }),
`\
2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!
`,
);
sink[Symbol.dispose]();
});
test("getRotatingFileSink() with small buffer size", () => {
const path = makeTempFileSync();
const sink: Sink & Disposable = getRotatingFileSink(path, {
maxSize: 200, // Larger maxSize to allow for buffering tests
bufferSize: 100, // Small buffer to test interaction with rotation
});
// Write first log entry - should be buffered
sink(debug);
assertEquals(fs.readFileSync(path, { encoding: "utf-8" }), "");
// Write second log entry - should trigger buffer flush
sink(info);
assertEquals(
fs.readFileSync(path, { encoding: "utf-8" }),
`\
2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!
`,
);
// Write third log entry - should be buffered again
sink(warning);
assertEquals(
fs.readFileSync(path, { encoding: "utf-8" }),
`\
2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!
`,
);
// Write fourth log entry - should flush buffer and likely trigger rotation
sink(error);
// Dispose should flush any remaining buffer content
sink[Symbol.dispose]();
// Verify final state - all entries should be written somewhere
const mainContent = fs.readFileSync(path, { encoding: "utf-8" });
let rotatedContent = "";
try {
rotatedContent = fs.readFileSync(`${path}.1`, { encoding: "utf-8" });
} catch {
// No rotation occurred
}
const allContent = mainContent + rotatedContent;
// All four log entries should be present exactly once in either main or rotated file
const expectedEntries = [
"2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!\n",
"2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!\n",
"2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456!\n",
"2023-11-14 22:13:20.000 +00:00 [ERR] my-app·junk: Hello, 123 & 456!\n",
];
for (const entry of expectedEntries) {
assertEquals(
allContent.includes(entry),
true,
`Missing log entry: ${entry.trim()}`,
);
}
// Verify each entry appears exactly once
for (const entry of expectedEntries) {
const firstIndex = allContent.indexOf(entry);
const lastIndex = allContent.lastIndexOf(entry);
assertEquals(firstIndex, lastIndex, `Duplicate log entry: ${entry.trim()}`);
}
});
test("getRotatingFileSink()", () => {
const path = makeTempFileSync();
const sink: Sink & Disposable = getRotatingFileSink(path, {
maxSize: 150,
bufferSize: 0, // Disable buffering for this test to maintain existing behavior
});
sink(debug);
assertEquals(
fs.readFileSync(path, { encoding: "utf-8" }),
"2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!\n",
);
sink(info);
assertEquals(
fs.readFileSync(path, { encoding: "utf-8" }),
`\
2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!
`,
);
sink(warning);
assertEquals(
fs.readFileSync(path, { encoding: "utf-8" }),
"2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456!\n",
);
assertEquals(
fs.readFileSync(`${path}.1`, { encoding: "utf-8" }),
`\
2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!
`,
);
sink(error);
assertEquals(
fs.readFileSync(path, { encoding: "utf-8" }),
`\
2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [ERR] my-app·junk: Hello, 123 & 456!
`,
);
assertEquals(
fs.readFileSync(`${path}.1`, { encoding: "utf-8" }),
`\
2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!
`,
);
sink(fatal);
sink[Symbol.dispose]();
assertEquals(
fs.readFileSync(path, { encoding: "utf-8" }),
"2023-11-14 22:13:20.000 +00:00 [FTL] my-app·junk: Hello, 123 & 456!\n",
);
assertEquals(
fs.readFileSync(`${path}.1`, { encoding: "utf-8" }),
`\
2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [ERR] my-app·junk: Hello, 123 & 456!
`,
);
assertEquals(
fs.readFileSync(`${path}.2`, { encoding: "utf-8" }),
`\
2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!
`,
);
const dirPath = fs.mkdtempSync(join(tmpdir(), "logtape-"));
const path2 = join(dirPath, "log");
const sink2: Sink & Disposable = getRotatingFileSink(path2, {
maxSize: 150,
bufferSize: 0, // Disable buffering for this test to maintain existing behavior
});
sink2(debug);
assertEquals(
fs.readFileSync(path2, { encoding: "utf-8" }),
"2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!\n",
);
sink2[Symbol.dispose]();
});
test("getBaseFileSink() with buffer edge cases", () => {
// Test negative bufferSize (should behave like bufferSize: 0)
const path1 = makeTempFileSync();
let sink1: Sink & Disposable;
if (isDeno) {
const driver: FileSinkDriver<Deno.FsFile> = {
openSync(path: string) {
return Deno.openSync(path, { create: true, append: true });
},
writeSync(fd, chunk) {
fd.writeSync(chunk);
},
flushSync(fd) {
fd.syncSync();
},
closeSync(fd) {
fd.close();
},
};
sink1 = getBaseFileSink(path1, { ...driver, bufferSize: -10 });
} else {
const driver: FileSinkDriver<number> = {
openSync(path: string) {
return fs.openSync(path, "a");
},
writeSync: fs.writeSync,
flushSync: fs.fsyncSync,
closeSync: fs.closeSync,
};
sink1 = getBaseFileSink(path1, { ...driver, bufferSize: -10 });
}
sink1(debug);
// With negative bufferSize, should write immediately
assertEquals(
fs.readFileSync(path1, { encoding: "utf-8" }),
"2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!\n",
);
sink1[Symbol.dispose]();
// Test bufferSize of 1 (very small)
const path2 = makeTempFileSync();
let sink2: Sink & Disposable;
if (isDeno) {
const driver: FileSinkDriver<Deno.FsFile> = {
openSync(path: string) {
return Deno.openSync(path, { create: true, append: true });
},
writeSync(fd, chunk) {
fd.writeSync(chunk);
},
flushSync(fd) {
fd.syncSync();
},
closeSync(fd) {
fd.close();
},
};
sink2 = getBaseFileSink(path2, { ...driver, bufferSize: 1 });
} else {
const driver: FileSinkDriver<number> = {
openSync(path: string) {
return fs.openSync(path, "a");
},
writeSync: fs.writeSync,
flushSync: fs.fsyncSync,
closeSync: fs.closeSync,
};
sink2 = getBaseFileSink(path2, { ...driver, bufferSize: 1 });
}
sink2(debug);
// With bufferSize of 1, should write immediately since log entry > 1 char
assertEquals(
fs.readFileSync(path2, { encoding: "utf-8" }),
"2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!\n",
);
sink2[Symbol.dispose]();
// Test very large bufferSize
const path3 = makeTempFileSync();
let sink3: Sink & Disposable;
if (isDeno) {
const driver: FileSinkDriver<Deno.FsFile> = {
openSync(path: string) {
return Deno.openSync(path, { create: true, append: true });
},
writeSync(fd, chunk) {
fd.writeSync(chunk);
},
flushSync(fd) {
fd.syncSync();
},
closeSync(fd) {
fd.close();
},
};
sink3 = getBaseFileSink(path3, { ...driver, bufferSize: 10000 });
} else {
const driver: FileSinkDriver<number> = {
openSync(path: string) {
return fs.openSync(path, "a");
},
writeSync: fs.writeSync,
flushSync: fs.fsyncSync,
closeSync: fs.closeSync,
};
sink3 = getBaseFileSink(path3, { ...driver, bufferSize: 10000 });
}
// Write multiple entries that shouldn't exceed the large buffer
sink3(debug);
sink3(info);
sink3(warning);
// Should still be buffered (file empty)
assertEquals(fs.readFileSync(path3, { encoding: "utf-8" }), "");
// Dispose should flush all buffered content
sink3[Symbol.dispose]();
assertEquals(
fs.readFileSync(path3, { encoding: "utf-8" }),
`\
2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!
2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456!
`,
);
});
test("getBaseFileSink() with time-based flushing", async () => {
const path = makeTempFileSync();
let sink: Sink & Disposable;
if (isDeno) {
const driver: FileSinkDriver<Deno.FsFile> = {
openSync(path: string) {
return Deno.openSync(path, { create: true, append: true });
},
writeSync(fd, chunk) {
fd.writeSync(chunk);
},
flushSync(fd) {
fd.syncSync();
},
closeSync(fd) {
fd.close();
},
};
sink = getBaseFileSink(path, {
...driver,
bufferSize: 1000, // Large buffer to prevent size-based flushing
flushInterval: 100, // 100ms flush interval for testing
});
} else {
const driver: FileSinkDriver<number> = {
openSync(path: string) {
return fs.openSync(path, "a");
},
writeSync: fs.writeSync,
flushSync: fs.fsyncSync,
closeSync: fs.closeSync,
};
sink = getBaseFileSink(path, {
...driver,
bufferSize: 1000, // Large buffer to prevent size-based flushing
flushInterval: 100, // 100ms flush interval for testing
});
}
// Create a log record with current timestamp
const record1 = { ...debug, timestamp: Date.now() };
sink(record1);
// Should be buffered (file empty initially)
assertEquals(fs.readFileSync(path, { encoding: "utf-8" }), "");
// Wait for flush interval to pass and write another record
await new Promise((resolve) => setTimeout(resolve, 150));
const record2 = { ...info, timestamp: Date.now() };
sink(record2);
// First record should now be flushed due to time interval
const content = fs.readFileSync(path, { encoding: "utf-8" });
assertEquals(content.includes("Hello, 123 & 456!"), true);
sink[Symbol.dispose]();
});
test("getRotatingFileSink() with time-based flushing", async () => {
const path = makeTempFileSync();
const sink: Sink & Disposable = getRotatingFileSink(path, {
maxSize: 1024 * 1024, // Large maxSize to prevent rotation
bufferSize: 1000, // Large buffer to prevent size-based flushing
flushInterval: 100, // 100ms flush interval for testing
});
// Create a log record with current timestamp
const record1 = { ...debug, timestamp: Date.now() };
sink(record1);
// Should be buffered (file empty initially)
assertEquals(fs.readFileSync(path, { encoding: "utf-8" }), "");
// Wait for flush interval to pass and write another record
await new Promise((resolve) => setTimeout(resolve, 150));
const record2 = { ...info, timestamp: Date.now() };
sink(record2);
// First record should now be flushed due to time interval
const content = fs.readFileSync(path, { encoding: "utf-8" });
assertEquals(content.includes("Hello, 123 & 456!"), true);
sink[Symbol.dispose]();
});
test("getBaseFileSink() with flushInterval disabled", () => {
const path = makeTempFileSync();
let sink: Sink & Disposable;
if (isDeno) {
const driver: FileSinkDriver<Deno.FsFile> = {
openSync(path: string) {
return Deno.openSync(path, { create: true, append: true });
},
writeSync(fd, chunk) {
fd.writeSync(chunk);
},
flushSync(fd) {
fd.syncSync();
},
closeSync(fd) {
fd.close();
},
};
sink = getBaseFileSink(path, {
...driver,
bufferSize: 1000, // Large buffer to prevent size-based flushing
flushInterval: 0, // Disable time-based flushing
});
} else {
const driver: FileSinkDriver<number> = {
openSync(path: string) {
return fs.openSync(path, "a");
},
writeSync: fs.writeSync,
flushSync: fs.fsyncSync,
closeSync: fs.closeSync,
};
sink = getBaseFileSink(path, {
...driver,
bufferSize: 1000, // Large buffer to prevent size-based flushing
flushInterval: 0, // Disable time-based flushing
});
}
// Create log records with simulated time gap
const now = Date.now();
const record1 = { ...debug, timestamp: now };
const record2 = { ...info, timestamp: now + 10000 }; // 10 seconds later
sink(record1);
sink(record2);
// Should still be buffered since time-based flushing is disabled
assertEquals(fs.readFileSync(path, { encoding: "utf-8" }), "");
// Only disposal should flush
sink[Symbol.dispose]();
const content = fs.readFileSync(path, { encoding: "utf-8" });
assertEquals(content.includes("Hello, 123 & 456!"), true);
});
// cSpell: ignore filesink