1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
|
//
// LogFileOutputStreamTests.swift
// MullvadVPNTests
//
// Created by Marco Nikic on 2025-01-21.
// Copyright © 2025 Mullvad VPN AB. All rights reserved.
//
@preconcurrency import Foundation
import Testing
@testable import MullvadLogging
@Suite("LogFileOutputStream Tests")
actor LogFileOutputStreamTests {
let fileManager = FileManager.default
var directoryPath: URL!
init() async throws {
directoryPath = FileManager.default.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
isDirectory: true
)
try fileManager.createDirectory(
at: directoryPath,
withIntermediateDirectories: true
)
}
deinit {
try? fileManager.removeItem(at: directoryPath)
}
@Test func logHeaderGetsWrittenAtFileStartAfterTruncation() async throws {
let header = "header"
let message = """
old
"""
let fileSizeLimit: UInt64 = 20
let fileURL = directoryPath.appendingPathComponent(UUID().uuidString)
let stream = LogFileOutputStream(
fileURL: fileURL,
header: header,
fileSizeLimit: fileSizeLimit,
newLineChunkReadSize: 3
)
// Fill the file with the word "old" to force truncation in half
for _ in 0..<3 {
stream.write(message)
}
/* At this point, the file contains the following string (of length 19)
"header\nold\nold\nold"
^
Half point of the file
Writing the word "new" goes over the file size limit (20),
so the file will get truncated to its half point.
In order to keep a nice UX for reading log, the stream will move the internal file cursor to after the next "\n"
character, and read the last half of the file in order to paste it at the beginning
after truncation.
In this example, the string "old\nold\n" will be buffered, which will then
get prepended with "header\n"
*/
stream.synchronize()
stream.write("new")
stream.synchronize()
let fileContents = try #require(
try String(contentsOf: fileURL, encoding: .utf8)
)
let expectedContents = """
header
old
old
new
"""
#expect(fileContents == expectedContents)
}
@Test func fileSizeCounterGetsResetAfterTruncation() async throws {
let header = "header"
let message = """
old
"""
let fileSizeLimit: UInt64 = 20
let fileURL = directoryPath.appendingPathComponent(UUID().uuidString)
let stream = LogFileOutputStream(
fileURL: fileURL,
header: header,
fileSizeLimit: fileSizeLimit
)
// Fill the file with the word "old" to force truncation in half
for _ in 0..<3 {
stream.write(message)
}
// File gets truncated in half here
stream.write("new")
stream.write("a")
stream.synchronize()
/// If the `partialFileSizeCounter` didn't get reset after truncating,
/// a new write will truncate the file again instead of just appending
let expectedContents = """
header
d
old
newa
"""
let fileContents = try #require(
try String(contentsOf: fileURL, encoding: .utf8)
)
#expect(fileContents == expectedContents)
}
}
|