Skip to content
Source
Sign inSign up
Open History to see the last commit

logging.test.ts

140 lines5.4 KB

1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5import { afterEach, describe, expect, it } from "vite-plus/test";
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { it as effectIt } from "@effect/vitest";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10​
11import { RotatingFileSink, makeAsyncRotatingFileSink } from "./logging.ts";
12​
13const tempDirectories: string[] = [];
14​
15const makeTempDirectory = (): string => {
16 const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-logging-"));
17 tempDirectories.push(directory);
18 return directory;
19};
20​
21const captureError = (run: () => unknown): unknown => {
22 try {
23 run();
24 } catch (cause) {
25 return cause;
26 }
27 throw new Error("Expected operation to throw");
28};
29​
30afterEach(() => {
31 for (const directory of tempDirectories.splice(0)) {
32 NodeFS.rmSync(directory, { recursive: true, force: true });
33 }
34});
35​
36effectIt.layer(NodeServices.layer)("rotating file sinks", (it) => {
37 it.effect.each(["synchronous", "asynchronous"] as const)(
38 "%s startup prunes only canonical rotation suffixes",
39 (mode) =>
40 Effect.gen(function* () {
41 const directory = makeTempDirectory();
42 const filePath = NodePath.join(directory, "debug.log");
43 const retained = ["1", "3", "04", "4.0", "4e0", "0x4", "+4", "notes"];
44 for (const suffix of [...retained, "4", "50"]) {
45 NodeFS.writeFileSync(`${filePath}.${suffix}`, suffix);
46 }
47 const options = { filePath, maxBytes: 100, maxFiles: 3 };
48​
49 const sink =
50 mode === "synchronous"
51 ? new RotatingFileSink(options)
52 : yield* makeAsyncRotatingFileSink(options);
53 if (sink instanceof RotatingFileSink) sink.write("current");
54 else yield* sink.write("current");
55​
56 expect(NodeFS.readdirSync(directory).sort()).toEqual(
57 ["debug.log", ...retained.map((suffix) => `debug.log.${suffix}`)].sort(),
58 );
59 expect(NodeFS.readFileSync(filePath, "utf8")).toBe("current");
60 for (const suffix of retained) {
61 expect(NodeFS.readFileSync(`${filePath}.${suffix}`, "utf8")).toBe(suffix);
62 }
63 }),
64 );
65​
66 it.effect("serializes concurrent appends across rotation and resumes an existing file", () =>
67 Effect.gen(function* () {
68 const directory = makeTempDirectory();
69 const filePath = NodePath.join(directory, "async.log");
70 NodeFS.writeFileSync(`${filePath}.50`, "obsolete");
71 const lines = Array.from(
72 { length: 100 },
73 (_, index) => `${index.toString().padStart(3, "0")}\n`,
74 );
75 const sink = yield* makeAsyncRotatingFileSink({ filePath, maxBytes: 32, maxFiles: 20 });
76 yield* Effect.forEach(lines, (line) => sink.write(line), {
77 concurrency: "unbounded",
78 discard: true,
79 });
80 const resumed = yield* makeAsyncRotatingFileSink({ filePath, maxBytes: 32, maxFiles: 20 });
81 yield* resumed.write("100\n");
82 expect(NodeFS.existsSync(`${filePath}.50`)).toBe(false);
83 const backups = NodeFS.readdirSync(directory)
84 .filter((name) => name.startsWith("async.log."))
85 .sort((a, b) => Number(b.slice(10)) - Number(a.slice(10)));
86 const contents = [...backups, "async.log"].map((name) =>
87 NodeFS.readFileSync(NodePath.join(directory, name), "utf8"),
88 );
89 expect(contents.every((chunk) => Buffer.byteLength(chunk) <= 32)).toBe(true);
90 expect(contents.join("")).toBe(lines.join("") + "100\n");
91 }),
92 );
93​
94 it.effect("reports a failed append and recovers its size before the next rotation", () =>
95 Effect.gen(function* () {
96 const directory = makeTempDirectory();
97 const filePath = NodePath.join(directory, "recovery.log");
98 NodeFS.writeFileSync(filePath, "old\n");
99 const fs = yield* FileSystem.FileSystem;
100 let attempts = 0;
101 const sink = yield* makeAsyncRotatingFileSink({ filePath, maxBytes: 8, maxFiles: 2 }).pipe(
102 Effect.provideService(FileSystem.FileSystem, {
103 ...fs,
104 writeFile: (path, bytes, options) =>
105 ++attempts === 1
106 ? fs.writeFile(directory, bytes, options)
107 : fs.writeFile(path, bytes, options),
108 }),
109 );
110 const failed = yield* sink.write("new\n").pipe(Effect.result);
111 expect(failed._tag).toBe("Failure");
112 if (failed._tag === "Failure") expect(failed.failure.operation).toBe("write");
113 yield* sink.write("new\n");
114 yield* sink.write("end\n");
115 expect(NodeFS.readFileSync(`${filePath}.1`, "utf8")).toBe("old\nnew\n");
116 expect(NodeFS.readFileSync(filePath, "utf8")).toBe("end\n");
117 }),
118 );
119});
120​
121describe("RotatingFileSink", () => {
122 it("never reports a rotation failure after successfully appending a chunk", () => {
123 const directory = makeTempDirectory();
124 const filePath = NodePath.join(directory, "log.ndjson");
125 NodeFS.mkdirSync(`${filePath}.1`);
126 const sink = new RotatingFileSink({
127 filePath,
128 maxBytes: 1,
129 maxFiles: 1,
130 throwOnError: true,
131 });
132​
133 sink.write("oversized");
134​
135 expect(NodeFS.readFileSync(filePath, "utf8")).toBe("oversized");
136 const thrown = captureError(() => sink.write("next"));
137 expect(thrown).toMatchObject({ operation: "rotate", filePath });
138 expect(NodeFS.readFileSync(filePath, "utf8")).toBe("oversized");
139 });
140});