@mux-magic/tools 0.1.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/applyRenameRegex.test.ts +49 -0
- package/src/applyRenameRegex.ts +22 -0
- package/src/index.ts +42 -0
- package/src/logMessage.test.ts +112 -9
- package/src/logMessage.ts +29 -0
- package/src/logging/context.test.ts +57 -0
- package/src/logging/context.ts +23 -0
- package/src/logging/lineSink.test.ts +135 -0
- package/src/logging/lineSink.ts +74 -0
- package/src/logging/logger.test.ts +154 -0
- package/src/logging/logger.ts +96 -0
- package/src/logging/mode.test.ts +36 -0
- package/src/logging/mode.ts +29 -0
- package/src/logging/startSpan.test.ts +150 -0
- package/src/logging/startSpan.ts +51 -0
- package/src/sourcePath.test.ts +16 -0
- package/src/sourcePath.ts +17 -0
- package/src/taskScheduler.injection.test.ts +72 -0
- package/src/taskScheduler.test.ts +673 -0
- package/src/taskScheduler.ts +414 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mux-magic/tools",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Reusable utilities (file system, logging, rxjs operators) shared between @mux-magic/server and sibling tools like Gallery-Downloader.",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest"
|
|
2
|
+
|
|
3
|
+
import { applyRenameRegex } from "./applyRenameRegex.js"
|
|
4
|
+
|
|
5
|
+
describe(applyRenameRegex.name, () => {
|
|
6
|
+
test("returns the input unchanged when renameRegex is undefined", () => {
|
|
7
|
+
expect(
|
|
8
|
+
applyRenameRegex("My Show - 01.mkv", undefined),
|
|
9
|
+
).toBe("My Show - 01.mkv")
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test("returns the input unchanged when the pattern does not match", () => {
|
|
13
|
+
expect(
|
|
14
|
+
applyRenameRegex("My Show - 01.mkv", {
|
|
15
|
+
pattern: "^\\[Group\\] ",
|
|
16
|
+
replacement: "",
|
|
17
|
+
}),
|
|
18
|
+
).toBe("My Show - 01.mkv")
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test("applies a simple replacement", () => {
|
|
22
|
+
expect(
|
|
23
|
+
applyRenameRegex("[Group] My Show - 01.mkv", {
|
|
24
|
+
pattern: "^\\[Group\\] ",
|
|
25
|
+
replacement: "",
|
|
26
|
+
}),
|
|
27
|
+
).toBe("My Show - 01.mkv")
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test("supports numbered capture groups", () => {
|
|
31
|
+
expect(
|
|
32
|
+
applyRenameRegex("[Group] My Show - 01 [1080p].mkv", {
|
|
33
|
+
pattern: "^\\[.*?\\] (.+?) \\[.*?\\](\\.\\w+)$",
|
|
34
|
+
replacement: "$1$2",
|
|
35
|
+
}),
|
|
36
|
+
).toBe("My Show - 01.mkv")
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test("supports named capture groups", () => {
|
|
40
|
+
expect(
|
|
41
|
+
applyRenameRegex("Show.s01e03.Episode.mkv", {
|
|
42
|
+
pattern:
|
|
43
|
+
"^(?<series>.+?)\\.(?<season>s\\d+)(?<episode>e\\d+)\\.(?<title>.+)\\.mkv$",
|
|
44
|
+
replacement:
|
|
45
|
+
"$<series> - $<season>$<episode> - $<title>.mkv",
|
|
46
|
+
}),
|
|
47
|
+
).toBe("Show - s01e03 - Episode.mkv")
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Canonical rename-regex shape consumed by `copyFiles`, `moveFiles`,
|
|
2
|
+
// and `renameFiles`. Worker 65 added optional `flags` (passed to the
|
|
3
|
+
// `RegExp` ctor as the second arg) and `sample` (UI-only documentation
|
|
4
|
+
// the runtime ignores). Pre-flags templates still satisfy this type
|
|
5
|
+
// because both fields are optional.
|
|
6
|
+
export type RenameRegex = {
|
|
7
|
+
pattern: string
|
|
8
|
+
replacement: string
|
|
9
|
+
flags?: string
|
|
10
|
+
sample?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const applyRenameRegex = (
|
|
14
|
+
name: string,
|
|
15
|
+
renameRegex: RenameRegex | undefined,
|
|
16
|
+
): string =>
|
|
17
|
+
renameRegex
|
|
18
|
+
? name.replace(
|
|
19
|
+
new RegExp(renameRegex.pattern, renameRegex.flags),
|
|
20
|
+
renameRegex.replacement,
|
|
21
|
+
)
|
|
22
|
+
: name
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,10 @@ export {
|
|
|
14
14
|
type CopyProgressEvent,
|
|
15
15
|
} from "./aclSafeCopyFile.js"
|
|
16
16
|
export { addFolderNameBeforeFilename } from "./addFolderNameBeforeFilename.js"
|
|
17
|
+
export {
|
|
18
|
+
applyRenameRegex,
|
|
19
|
+
type RenameRegex,
|
|
20
|
+
} from "./applyRenameRegex.js"
|
|
17
21
|
export { captureConsoleMessage } from "./captureConsoleMessage.js"
|
|
18
22
|
export { captureLogMessage } from "./captureLogMessage.js"
|
|
19
23
|
export { cleanupFilename } from "./cleanupFilename.js"
|
|
@@ -42,6 +46,29 @@ export {
|
|
|
42
46
|
} from "./listDirectoryEntries.js"
|
|
43
47
|
export { logAndRethrowPipelineError } from "./logAndRethrowPipelineError.js"
|
|
44
48
|
export { logAndSwallowPipelineError } from "./logAndSwallowPipelineError.js"
|
|
49
|
+
export {
|
|
50
|
+
getLoggingContext,
|
|
51
|
+
type LoggerContext,
|
|
52
|
+
loggingContext,
|
|
53
|
+
withLoggingContext,
|
|
54
|
+
} from "./logging/context.js"
|
|
55
|
+
export { formatLogLine } from "./logging/lineSink.js"
|
|
56
|
+
export {
|
|
57
|
+
__resetLogSinksForTests,
|
|
58
|
+
getLogger,
|
|
59
|
+
type Logger,
|
|
60
|
+
type LogLevel,
|
|
61
|
+
type LogRecord,
|
|
62
|
+
type LogSink,
|
|
63
|
+
registerLogSink,
|
|
64
|
+
} from "./logging/logger.js"
|
|
65
|
+
export {
|
|
66
|
+
__resetLoggingModeForTests,
|
|
67
|
+
getLoggingMode,
|
|
68
|
+
type LoggingMode,
|
|
69
|
+
setLoggingMode,
|
|
70
|
+
} from "./logging/mode.js"
|
|
71
|
+
export { startSpan } from "./logging/startSpan.js"
|
|
45
72
|
export {
|
|
46
73
|
createAddColorToChalk,
|
|
47
74
|
createLogMessage,
|
|
@@ -53,6 +80,21 @@ export {
|
|
|
53
80
|
export { makeDirectory } from "./makeDirectory.js"
|
|
54
81
|
export { naturalSort } from "./naturalSort.js"
|
|
55
82
|
export { replaceFileExtension } from "./replaceFileExtension.js"
|
|
83
|
+
export {
|
|
84
|
+
SOURCE_PATH_FIELD_NAME,
|
|
85
|
+
SOURCE_PATH_LABEL,
|
|
86
|
+
type SourcePath,
|
|
87
|
+
} from "./sourcePath.js"
|
|
88
|
+
export {
|
|
89
|
+
__resetTaskSchedulerForTests,
|
|
90
|
+
initTaskScheduler,
|
|
91
|
+
mergeMapOrdered,
|
|
92
|
+
registerJobClaim,
|
|
93
|
+
runTask,
|
|
94
|
+
runTasks,
|
|
95
|
+
runTasksOrdered,
|
|
96
|
+
unregisterJobClaim,
|
|
97
|
+
} from "./taskScheduler.js"
|
|
56
98
|
export {
|
|
57
99
|
getOperatorValue,
|
|
58
100
|
runPromiseScheduler,
|
package/src/logMessage.test.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { Chalk } from "chalk"
|
|
2
2
|
import { describe, expect, test } from "vitest"
|
|
3
3
|
import { captureConsoleMessage } from "./captureConsoleMessage.js"
|
|
4
|
+
import {
|
|
5
|
+
__resetLogSinksForTests,
|
|
6
|
+
type LogRecord,
|
|
7
|
+
registerLogSink,
|
|
8
|
+
} from "./logging/logger.js"
|
|
9
|
+
import {
|
|
10
|
+
__resetLoggingModeForTests,
|
|
11
|
+
setLoggingMode,
|
|
12
|
+
} from "./logging/mode.js"
|
|
4
13
|
import {
|
|
5
14
|
createAddColorToChalk,
|
|
6
15
|
createLogMessage,
|
|
@@ -118,9 +127,11 @@ describe(createLogMessage.name, () => {
|
|
|
118
127
|
})("HELLO WORLD")
|
|
119
128
|
|
|
120
129
|
expect(consoleSpy).toHaveBeenCalledOnce()
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
130
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
131
|
+
expect.stringContaining("HELLO WORLD"),
|
|
132
|
+
expect.anything(),
|
|
133
|
+
expect.anything(),
|
|
134
|
+
expect.anything(),
|
|
124
135
|
)
|
|
125
136
|
})
|
|
126
137
|
})
|
|
@@ -175,8 +186,11 @@ describe(logError.name, () => {
|
|
|
175
186
|
captureConsoleMessage("error", (consoleSpy) => {
|
|
176
187
|
logError("ERROR")
|
|
177
188
|
|
|
178
|
-
expect(consoleSpy
|
|
179
|
-
"ERROR",
|
|
189
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
190
|
+
expect.stringContaining("ERROR"),
|
|
191
|
+
expect.anything(),
|
|
192
|
+
expect.anything(),
|
|
193
|
+
expect.anything(),
|
|
180
194
|
)
|
|
181
195
|
})
|
|
182
196
|
})
|
|
@@ -187,8 +201,11 @@ describe(logInfo.name, () => {
|
|
|
187
201
|
captureConsoleMessage("info", (consoleSpy) => {
|
|
188
202
|
logInfo("INFO")
|
|
189
203
|
|
|
190
|
-
expect(consoleSpy
|
|
191
|
-
"INFO",
|
|
204
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
205
|
+
expect.stringContaining("INFO"),
|
|
206
|
+
expect.anything(),
|
|
207
|
+
expect.anything(),
|
|
208
|
+
expect.anything(),
|
|
192
209
|
)
|
|
193
210
|
})
|
|
194
211
|
})
|
|
@@ -199,9 +216,95 @@ describe(logWarning.name, () => {
|
|
|
199
216
|
captureConsoleMessage("warn", (consoleSpy) => {
|
|
200
217
|
logWarning("WARNING")
|
|
201
218
|
|
|
202
|
-
expect(consoleSpy
|
|
203
|
-
"WARNING",
|
|
219
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
220
|
+
expect.stringContaining("WARNING"),
|
|
221
|
+
expect.anything(),
|
|
222
|
+
expect.anything(),
|
|
223
|
+
expect.anything(),
|
|
204
224
|
)
|
|
205
225
|
})
|
|
206
226
|
})
|
|
207
227
|
})
|
|
228
|
+
|
|
229
|
+
describe("logMessage mode-awareness", () => {
|
|
230
|
+
test('"api" mode emits a structured record AND skips chalk console', () => {
|
|
231
|
+
__resetLogSinksForTests()
|
|
232
|
+
let records: readonly LogRecord[] = []
|
|
233
|
+
registerLogSink((record) => {
|
|
234
|
+
records = records.concat(record)
|
|
235
|
+
})
|
|
236
|
+
setLoggingMode("api")
|
|
237
|
+
|
|
238
|
+
captureConsoleMessage("info", (consoleSpy) => {
|
|
239
|
+
logInfo("SEQUENCE", "Step step1 starting.")
|
|
240
|
+
expect(consoleSpy).not.toHaveBeenCalled()
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
__resetLoggingModeForTests()
|
|
244
|
+
__resetLogSinksForTests()
|
|
245
|
+
|
|
246
|
+
expect(records).toHaveLength(1)
|
|
247
|
+
expect(records[0]).toMatchObject({
|
|
248
|
+
level: "info",
|
|
249
|
+
tag: "SEQUENCE",
|
|
250
|
+
msg: "Step step1 starting.",
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('"cli" mode (default) emits NO structured record', () => {
|
|
255
|
+
__resetLogSinksForTests()
|
|
256
|
+
let records: readonly LogRecord[] = []
|
|
257
|
+
registerLogSink((record) => {
|
|
258
|
+
records = records.concat(record)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
captureConsoleMessage("info", () => {
|
|
262
|
+
logInfo("SEQUENCE", "Step step1 starting.")
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
expect(records).toHaveLength(0)
|
|
266
|
+
__resetLogSinksForTests()
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
test('"cli-debug" mode emits BOTH a structured record AND the chalk console line', () => {
|
|
270
|
+
__resetLogSinksForTests()
|
|
271
|
+
let records: readonly LogRecord[] = []
|
|
272
|
+
registerLogSink((record) => {
|
|
273
|
+
records = records.concat(record)
|
|
274
|
+
})
|
|
275
|
+
setLoggingMode("cli-debug")
|
|
276
|
+
|
|
277
|
+
captureConsoleMessage("info", (consoleSpy) => {
|
|
278
|
+
logInfo("SEQUENCE", "Step step1 starting.")
|
|
279
|
+
expect(consoleSpy).toHaveBeenCalled()
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
__resetLoggingModeForTests()
|
|
283
|
+
__resetLogSinksForTests()
|
|
284
|
+
|
|
285
|
+
expect(records).toHaveLength(1)
|
|
286
|
+
expect(records[0]?.tag).toBe("SEQUENCE")
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test('"api" mode for logError emits a structured "error" record', () => {
|
|
290
|
+
__resetLogSinksForTests()
|
|
291
|
+
let records: readonly LogRecord[] = []
|
|
292
|
+
registerLogSink((record) => {
|
|
293
|
+
records = records.concat(record)
|
|
294
|
+
})
|
|
295
|
+
setLoggingMode("api")
|
|
296
|
+
|
|
297
|
+
captureConsoleMessage("error", () => {
|
|
298
|
+
logError("SEQUENCE", "boom")
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
__resetLoggingModeForTests()
|
|
302
|
+
__resetLogSinksForTests()
|
|
303
|
+
|
|
304
|
+
expect(records[0]).toMatchObject({
|
|
305
|
+
level: "error",
|
|
306
|
+
tag: "SEQUENCE",
|
|
307
|
+
msg: "boom",
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
})
|
package/src/logMessage.ts
CHANGED
|
@@ -6,6 +6,12 @@ import {
|
|
|
6
6
|
type ForegroundColorName,
|
|
7
7
|
} from "chalk"
|
|
8
8
|
|
|
9
|
+
import {
|
|
10
|
+
getLogger,
|
|
11
|
+
type LogLevel,
|
|
12
|
+
} from "./logging/logger.js"
|
|
13
|
+
import { getLoggingMode } from "./logging/mode.js"
|
|
14
|
+
|
|
9
15
|
export const createAddColorToChalk =
|
|
10
16
|
(chalkColor?: ColorName) =>
|
|
11
17
|
(chalkInstance: ChalkInstance) =>
|
|
@@ -92,6 +98,29 @@ export const createLogMessage =
|
|
|
92
98
|
)
|
|
93
99
|
: null
|
|
94
100
|
|
|
101
|
+
const mode = getLoggingMode()
|
|
102
|
+
const messageArray = message || content
|
|
103
|
+
|
|
104
|
+
if (mode === "api" || mode === "cli-debug") {
|
|
105
|
+
const structuredMsg = (
|
|
106
|
+
messageArray as readonly unknown[]
|
|
107
|
+
)
|
|
108
|
+
.map((part) =>
|
|
109
|
+
typeof part === "string" ? part : String(part),
|
|
110
|
+
)
|
|
111
|
+
.join(" ")
|
|
112
|
+
.trim()
|
|
113
|
+
const structuredLevel: LogLevel =
|
|
114
|
+
logType === "log" ? "info" : logType
|
|
115
|
+
getLogger()[structuredLevel](structuredMsg, {
|
|
116
|
+
tag: title,
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (mode === "api") {
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
95
124
|
console[logType](
|
|
96
125
|
optionallyColoredChalk(`[${title}]`),
|
|
97
126
|
"\n",
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getLoggingContext,
|
|
5
|
+
loggingContext,
|
|
6
|
+
withLoggingContext,
|
|
7
|
+
} from "./context.js"
|
|
8
|
+
|
|
9
|
+
describe(withLoggingContext.name, () => {
|
|
10
|
+
test("seeds the context for the duration of fn", () => {
|
|
11
|
+
const seen = withLoggingContext({ jobId: "j1" }, () =>
|
|
12
|
+
getLoggingContext(),
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
expect(seen).toEqual({ jobId: "j1" })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test("returns an empty context outside any run", () => {
|
|
19
|
+
expect(getLoggingContext()).toEqual({})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test("nested call merges parent bindings under the child", () => {
|
|
23
|
+
const seen = withLoggingContext(
|
|
24
|
+
{ jobId: "j1", stepIndex: 0 },
|
|
25
|
+
() =>
|
|
26
|
+
withLoggingContext(
|
|
27
|
+
{ stepIndex: 2, fileId: "f" },
|
|
28
|
+
() => getLoggingContext(),
|
|
29
|
+
),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
expect(seen).toEqual({
|
|
33
|
+
jobId: "j1",
|
|
34
|
+
stepIndex: 2,
|
|
35
|
+
fileId: "f",
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test("propagates through awaited promises", async () => {
|
|
40
|
+
const seen = await withLoggingContext(
|
|
41
|
+
{ jobId: "j-async" },
|
|
42
|
+
async () => {
|
|
43
|
+
await Promise.resolve()
|
|
44
|
+
await new Promise((resolve) =>
|
|
45
|
+
setTimeout(resolve, 0),
|
|
46
|
+
)
|
|
47
|
+
return getLoggingContext()
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
expect(seen.jobId).toBe("j-async")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test("`loggingContext` is a single AsyncLocalStorage instance", () => {
|
|
55
|
+
expect(loggingContext.getStore).toBeTypeOf("function")
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks"
|
|
2
|
+
|
|
3
|
+
export type LoggerContext = {
|
|
4
|
+
jobId?: string
|
|
5
|
+
stepIndex?: number
|
|
6
|
+
fileId?: string
|
|
7
|
+
traceId?: string
|
|
8
|
+
spanId?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const loggingContext =
|
|
12
|
+
new AsyncLocalStorage<LoggerContext>()
|
|
13
|
+
|
|
14
|
+
export const getLoggingContext = (): LoggerContext =>
|
|
15
|
+
loggingContext.getStore() ?? {}
|
|
16
|
+
|
|
17
|
+
export const withLoggingContext = <T>(
|
|
18
|
+
bindings: LoggerContext,
|
|
19
|
+
fn: () => T,
|
|
20
|
+
): T => {
|
|
21
|
+
const merged = { ...getLoggingContext(), ...bindings }
|
|
22
|
+
return loggingContext.run(merged, fn)
|
|
23
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest"
|
|
2
|
+
|
|
3
|
+
import { formatLogLine } from "./lineSink.js"
|
|
4
|
+
|
|
5
|
+
describe(formatLogLine.name, () => {
|
|
6
|
+
test("uses bracketed local-time timestamp prefix matching legacy logCapture", () => {
|
|
7
|
+
const now = new Date()
|
|
8
|
+
now.setHours(8, 21, 33, 512)
|
|
9
|
+
|
|
10
|
+
const line = formatLogLine(
|
|
11
|
+
{ level: "info", msg: "copy finished" },
|
|
12
|
+
now,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
expect(line.startsWith("[08:21:33.512]")).toBe(true)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test("emits `<timestamp> <level> <msg>` for a record with no extra fields", () => {
|
|
19
|
+
const now = new Date()
|
|
20
|
+
now.setHours(0, 0, 0, 0)
|
|
21
|
+
|
|
22
|
+
const line = formatLogLine(
|
|
23
|
+
{ level: "warn", msg: "be careful" },
|
|
24
|
+
now,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
expect(line).toBe("[00:00:00.000] warn be careful")
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test("renders extra fields inline as field=value before the msg", () => {
|
|
31
|
+
const now = new Date()
|
|
32
|
+
now.setHours(12, 0, 0, 0)
|
|
33
|
+
|
|
34
|
+
const line = formatLogLine(
|
|
35
|
+
{
|
|
36
|
+
level: "info",
|
|
37
|
+
msg: "step started",
|
|
38
|
+
stepIndex: 2,
|
|
39
|
+
fileId: "foo.mkv",
|
|
40
|
+
},
|
|
41
|
+
now,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
expect(line).toBe(
|
|
45
|
+
"[12:00:00.000] info stepIndex=2 fileId=foo.mkv step started",
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test("does not render undefined fields", () => {
|
|
50
|
+
const now = new Date()
|
|
51
|
+
now.setHours(0, 0, 0, 0)
|
|
52
|
+
|
|
53
|
+
const line = formatLogLine(
|
|
54
|
+
{
|
|
55
|
+
level: "info",
|
|
56
|
+
msg: "m",
|
|
57
|
+
jobId: undefined,
|
|
58
|
+
stepIndex: 1,
|
|
59
|
+
},
|
|
60
|
+
now,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
expect(line).not.toContain("jobId")
|
|
64
|
+
expect(line).toContain("stepIndex=1")
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test("quotes string values that contain whitespace", () => {
|
|
68
|
+
const now = new Date()
|
|
69
|
+
now.setHours(0, 0, 0, 0)
|
|
70
|
+
|
|
71
|
+
const line = formatLogLine(
|
|
72
|
+
{
|
|
73
|
+
level: "info",
|
|
74
|
+
msg: "named",
|
|
75
|
+
detail: "two words",
|
|
76
|
+
},
|
|
77
|
+
now,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
expect(line).toContain(`detail="two words"`)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("renders `[ts] [TAG] msg` when a `tag` field is present (preserves legacy chalk-stripped shape)", () => {
|
|
84
|
+
const now = new Date()
|
|
85
|
+
now.setHours(8, 21, 33, 512)
|
|
86
|
+
|
|
87
|
+
const line = formatLogLine(
|
|
88
|
+
{
|
|
89
|
+
level: "info",
|
|
90
|
+
msg: "Step step1 starting.",
|
|
91
|
+
tag: "SEQUENCE",
|
|
92
|
+
},
|
|
93
|
+
now,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
expect(line).toBe(
|
|
97
|
+
"[08:21:33.512] [SEQUENCE] Step step1 starting.",
|
|
98
|
+
)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test("tag-mode line still appends remaining extra fields", () => {
|
|
102
|
+
const now = new Date()
|
|
103
|
+
now.setHours(0, 0, 0, 0)
|
|
104
|
+
|
|
105
|
+
const line = formatLogLine(
|
|
106
|
+
{
|
|
107
|
+
level: "info",
|
|
108
|
+
msg: "Step step1 starting.",
|
|
109
|
+
tag: "SEQUENCE",
|
|
110
|
+
stepIndex: 1,
|
|
111
|
+
},
|
|
112
|
+
now,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
expect(line).toBe(
|
|
116
|
+
"[00:00:00.000] [SEQUENCE] stepIndex=1 Step step1 starting.",
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test("serialises nested objects via JSON", () => {
|
|
121
|
+
const now = new Date()
|
|
122
|
+
now.setHours(0, 0, 0, 0)
|
|
123
|
+
|
|
124
|
+
const line = formatLogLine(
|
|
125
|
+
{
|
|
126
|
+
level: "error",
|
|
127
|
+
msg: "boom",
|
|
128
|
+
meta: { code: 42 },
|
|
129
|
+
},
|
|
130
|
+
now,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
expect(line).toContain(`meta={"code":42}`)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { LogRecord } from "./logger.js"
|
|
2
|
+
|
|
3
|
+
const RESERVED_KEYS = new Set(["level", "msg"])
|
|
4
|
+
|
|
5
|
+
const pad = (value: number, width: number): string =>
|
|
6
|
+
String(value).padStart(width, "0")
|
|
7
|
+
|
|
8
|
+
const formatTimestamp = (date: Date): string =>
|
|
9
|
+
`[${pad(date.getHours(), 2)}:${pad(date.getMinutes(), 2)}:${pad(
|
|
10
|
+
date.getSeconds(),
|
|
11
|
+
2,
|
|
12
|
+
)}.${pad(date.getMilliseconds(), 3)}]`
|
|
13
|
+
|
|
14
|
+
const formatValue = (value: unknown): string => {
|
|
15
|
+
if (value === null) {
|
|
16
|
+
return "null"
|
|
17
|
+
}
|
|
18
|
+
if (typeof value === "string") {
|
|
19
|
+
return /\s/.test(value) ? `"${value}"` : value
|
|
20
|
+
}
|
|
21
|
+
if (
|
|
22
|
+
typeof value === "number" ||
|
|
23
|
+
typeof value === "boolean"
|
|
24
|
+
) {
|
|
25
|
+
return String(value)
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
return JSON.stringify(value)
|
|
29
|
+
} catch {
|
|
30
|
+
return String(value)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const renderField = ([key, value]: [
|
|
35
|
+
string,
|
|
36
|
+
unknown,
|
|
37
|
+
]): string => `${key}=${formatValue(value)}`
|
|
38
|
+
|
|
39
|
+
const isRenderableEntry =
|
|
40
|
+
(excludedKeys: ReadonlySet<string>) =>
|
|
41
|
+
([key, value]: [string, unknown]): boolean =>
|
|
42
|
+
!excludedKeys.has(key) && value !== undefined
|
|
43
|
+
|
|
44
|
+
const joinFields = (fields: readonly string[]): string =>
|
|
45
|
+
fields.length > 0 ? ` ${fields.join(" ")}` : ""
|
|
46
|
+
|
|
47
|
+
const TAG_MODE_EXCLUDED_KEYS: ReadonlySet<string> = new Set(
|
|
48
|
+
[...RESERVED_KEYS, "tag"],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
// When a record carries a `tag` field (set by the `logInfo/logError/logWarning`
|
|
52
|
+
// bridge in api mode), render as `[ts] [TAG] msg` so the SSE feed stays byte-
|
|
53
|
+
// identical to today's chalk-stripped console-patch output. Anything else
|
|
54
|
+
// renders in the structured form `[ts] level field=value... msg`.
|
|
55
|
+
export const formatLogLine = (
|
|
56
|
+
record: LogRecord,
|
|
57
|
+
now: Date = new Date(),
|
|
58
|
+
): string => {
|
|
59
|
+
if (typeof record.tag === "string") {
|
|
60
|
+
const extraPart = joinFields(
|
|
61
|
+
Object.entries(record)
|
|
62
|
+
.filter(isRenderableEntry(TAG_MODE_EXCLUDED_KEYS))
|
|
63
|
+
.map(renderField),
|
|
64
|
+
)
|
|
65
|
+
return `${formatTimestamp(now)} [${record.tag}]${extraPart} ${record.msg}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const fieldPart = joinFields(
|
|
69
|
+
Object.entries(record)
|
|
70
|
+
.filter(isRenderableEntry(RESERVED_KEYS))
|
|
71
|
+
.map(renderField),
|
|
72
|
+
)
|
|
73
|
+
return `${formatTimestamp(now)} ${record.level}${fieldPart} ${record.msg}`
|
|
74
|
+
}
|