Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/tests/smoke/logging/log-level-and-formats.test.ts
12921 views
1
/*
2
* log-level-direct.test.ts
3
*
4
* Copyright (C) 2020-2025 Posit Software, PBC
5
*
6
*/
7
8
import { basename, extname } from "../../../src/deno_ral/path.ts";
9
import { execProcess } from "../../../src/core/process.ts";
10
import { md5HashSync } from "../../../src/core/hash.ts";
11
import { safeRemoveIfExists } from "../../../src/core/path.ts";
12
import { quartoDevCmd, outputForInput } from "../../utils.ts";
13
import { assert } from "testing/asserts";
14
import { LogFormat } from "../../../src/core/log.ts";
15
import { existsSync } from "../../../src/deno_ral/fs.ts";
16
import { readExecuteOutput } from "../../test.ts";
17
18
// Simple minimal document for testing
19
const testDoc = "docs/minimal.qmd";
20
const testDocWithError = "docs/logging/lua-error.qmd";
21
22
// NOTE: We intentionally do NOT test environment variables (QUARTO_LOG, QUARTO_LOG_LEVEL, QUARTO_LOG_FORMAT)
23
// because they can interfere with other tests running concurrently in the Deno environment.
24
// Instead, we focus on testing the CLI arguments which are isolated to each test.
25
26
// Those tests are not using testQuartoCmd because they are testing the log level directly
27
// and custom helpers in tests() are modifying the logging configuration.
28
// This is why we use Deno.test directly here.
29
30
function testLogDirectly(options: {
31
testName: string,
32
level: string,
33
format?: LogFormat,
34
logFile?: string,
35
fileToRender?: string,
36
quiet?: boolean,
37
expectedOutputs?: {
38
// To test log output for document with errors
39
shouldSucceed?: boolean
40
// For plain format, we can check for specific text in the output
41
shouldContain?: string[],
42
shouldNotContain?: string[],
43
// For JSON format, we can also check for specific log levels
44
shouldContainLevel?: string[],
45
shouldNotContainLevel?: string[],
46
}
47
}) {
48
Deno.test({
49
name: options.testName,
50
fn: async () => {
51
// Generate a unique log file path if one was provided
52
// This is to avoid conflicts when running multiple tests in parallel
53
let logFile = options.logFile;
54
if (logFile) {
55
const testNameHash = md5HashSync(options.testName);
56
const extension = extname(logFile);
57
const baseName = basename(logFile, extension);
58
logFile = `${baseName}-${testNameHash}${extension}`;
59
}
60
61
try {
62
// Build command args
63
const args = ["render"];
64
65
// Add file path (default to testDoc if not specified)
66
if (!options.fileToRender) {
67
options.fileToRender = testDoc;
68
}
69
args.push(options.fileToRender);
70
71
// Add log level
72
args.push("--log-level", options.level);
73
74
// Add format if specified
75
if (options.format) {
76
args.push("--log-format", options.format);
77
}
78
79
if (logFile) {
80
args.push("--log", logFile);
81
}
82
83
// Add quiet if specified
84
if (options.quiet) {
85
args.push("--quiet");
86
}
87
88
// Execute quarto directly
89
const process = await execProcess({
90
cmd: quartoDevCmd(),
91
args: args,
92
stdout: "piped",
93
stderr: "piped"
94
});
95
96
// Get stdout/stderr with fallback to empty string
97
const stdout = process.stdout || "";
98
const stderr = process.stderr || "";
99
const allOutput = stdout + stderr;
100
101
// Check success/failure expectation
102
if (options.expectedOutputs?.shouldSucceed !== undefined) {
103
assert(
104
process.success === options.expectedOutputs.shouldSucceed,
105
options.expectedOutputs.shouldSucceed
106
? `Process unexpectedly failed: ${stderr}`
107
: `Process unexpectedly succeeded (expected failure)`
108
);
109
}
110
111
// Check for expected content
112
if (options.expectedOutputs?.shouldContain) {
113
for (const text of options.expectedOutputs.shouldContain) {
114
assert(
115
allOutput.includes(text),
116
`Output should contain '${text}' but didn't.\nOutput: ${allOutput}`
117
);
118
}
119
}
120
121
// Check for unwanted content
122
if (options.expectedOutputs?.shouldNotContain) {
123
for (const text of options.expectedOutputs.shouldNotContain) {
124
assert(
125
!allOutput.includes(text),
126
`Output shouldn't contain '${text}' but did.\nOutput: ${allOutput}`
127
);
128
}
129
}
130
131
// For quiet mode, verify no output
132
if (options.quiet) {
133
assert(
134
stdout.trim() === "",
135
`Expected no stdout with --quiet option, but found: ${stdout}`
136
);
137
}
138
139
140
// If JSON format is specified, verify the output is valid JSON
141
if (logFile && options.format === "json-stream") {
142
assert(existsSync(logFile), "Log file should exist");
143
let foundValidJson = false;
144
try {
145
const outputs = readExecuteOutput(logFile);
146
foundValidJson = true;
147
outputs.filter((out) => out.msg !== "" && options.expectedOutputs?.shouldNotContainLevel?.includes(out.levelName)).forEach(
148
(out) => {
149
assert(false, `JSON output should not contain level ${out.levelName}, but found: ${out.msg}`);
150
}
151
);
152
outputs.filter((out) => out.msg !== "" && options.expectedOutputs?.shouldContainLevel?.includes(out.levelName)).forEach(
153
(out) => {
154
let json = undefined;
155
try {
156
json = JSON.parse(out.msg);
157
} catch {
158
assert(false, "Error parsing JSON returned by quarto meta");
159
}
160
assert(
161
Object.keys(json).length > 0,
162
"JSON returned by quarto meta seems invalid",
163
);
164
}
165
);
166
167
} catch (e) {}
168
assert(foundValidJson, "JSON format should produce valid JSON output");
169
}
170
} finally {
171
// Clean up log file if it exists
172
if (logFile) {
173
safeRemoveIfExists(logFile);
174
}
175
// Clean up any rendered output files
176
if (options.fileToRender) {
177
const output = outputForInput(options.fileToRender, 'html');
178
safeRemoveIfExists(output.outputPath);
179
safeRemoveIfExists(output.supportPath);
180
}
181
}
182
}
183
});
184
};
185
186
// This will always be shown in debug output as we'll show pandoc call
187
const debugHintText = "pandoc --verbose --trace";
188
// This will be shown in info output
189
const infoHintText = function(testDoc: string) {
190
return `Output created: ${basename(testDoc, extname(testDoc))}.html`;
191
};
192
193
testLogDirectly({
194
testName: "Plain format - DEBUG level should show all log messages",
195
level: "debug",
196
format: "plain",
197
fileToRender: testDoc,
198
expectedOutputs: {
199
shouldContain: [debugHintText, infoHintText(testDoc)],
200
shouldSucceed: true
201
}
202
});
203
204
testLogDirectly({
205
testName: "Plain format - INFO level should not show DEBUG messages",
206
level: "info",
207
format: "plain",
208
fileToRender: testDoc,
209
expectedOutputs: {
210
shouldContain: [infoHintText(testDoc)],
211
shouldNotContain: [debugHintText],
212
shouldSucceed: true
213
}
214
});
215
216
testLogDirectly({
217
testName: "Plain format - WARN level should not show INFO or DEBUG messages",
218
level: "warn",
219
format: "plain",
220
fileToRender: testDocWithError,
221
expectedOutputs: {
222
shouldContain: ["WARN:", "ERROR:"],
223
shouldNotContain: [debugHintText, infoHintText(testDocWithError)],
224
shouldSucceed: false
225
}
226
});
227
228
testLogDirectly({
229
testName: "Plain format - ERROR level should only show ERROR messages",
230
level: "error",
231
format: "plain",
232
fileToRender: testDocWithError,
233
expectedOutputs: {
234
shouldContain: ["ERROR:"],
235
shouldNotContain: [debugHintText, infoHintText(testDocWithError), "WARN:"],
236
shouldSucceed: false
237
}
238
});
239
240
testLogDirectly({
241
testName: "Json-Stream format - should produce parseable JSON in a log file with INFO level",
242
level: "info",
243
format: "json-stream",
244
logFile: "test-log.json",
245
expectedOutputs: {
246
shouldSucceed: true,
247
shouldContainLevel: ["INFO"],
248
shouldNotContainLevel: ["DEBUG"]
249
}
250
});
251
252
testLogDirectly({
253
testName: "Json-Stream format - should produce parseable JSON in a log file with DEBUG level",
254
level: "debug",
255
format: "json-stream",
256
logFile: "test-log.json",
257
expectedOutputs: {
258
shouldSucceed: true,
259
shouldContainLevel: ["DEBUG", "INFO"],
260
}
261
});
262
263
testLogDirectly({
264
testName: "Json-Stream format - should produce parseable JSON in a log file with WARN level",
265
level: "warn",
266
format: "json-stream",
267
logFile: "test-log.json",
268
fileToRender: testDocWithError,
269
expectedOutputs: {
270
shouldSucceed: false,
271
shouldContainLevel: ["WARN", "ERROR"],
272
shouldNotContainLevel: ["INFO", "DEBUG"],
273
}
274
});
275
276
testLogDirectly({
277
testName: "Json-Stream format - should produce parseable JSON in a log file with ERROR level",
278
level: "error",
279
format: "json-stream",
280
logFile: "test-log.json",
281
fileToRender: testDocWithError,
282
expectedOutputs: {
283
shouldSucceed: false,
284
shouldContainLevel: ["ERROR"],
285
shouldNotContainLevel: ["DEBUG", "INFO", "WARN"]
286
}
287
});
288
289
290
// 6. Testing quiet mode
291
testLogDirectly({
292
testName: "Quiet mode should suppress all console output",
293
level: "debug",
294
format: "plain",
295
quiet: true,
296
expectedOutputs: {
297
shouldSucceed: true
298
}
299
});
300
301
testLogDirectly({
302
testName: "Quiet mode should not suppress all output in JSON log file",
303
level: "debug",
304
format: "json-stream",
305
logFile: "test-log.json",
306
quiet: true,
307
expectedOutputs: {
308
shouldSucceed: true,
309
shouldContainLevel: ["DEBUG", "INFO"],
310
}
311
});
312
313
// 7. Testing quiet mode with errors
314
testLogDirectly({
315
testName: "Plain format - Quiet mode should suppress output even with errors",
316
level: "debug",
317
format: "plain",
318
quiet: true,
319
fileToRender: testDocWithError,
320
expectedOutputs: {
321
shouldSucceed: false
322
}
323
});
324
325
testLogDirectly({
326
testName: "Json-Stream - Quiet mode should not suppress output even with errors",
327
level: "debug",
328
format: "json-stream",
329
logFile: "test-log.json",
330
quiet: true,
331
fileToRender: testDocWithError,
332
expectedOutputs: {
333
shouldSucceed: false,
334
shouldContainLevel: ["DEBUG", "INFO", "WARN", "ERROR"]
335
}
336
});
337
338
339
testLogDirectly({
340
testName: "Log level should be case-insensitive (INFO vs info)",
341
level: "INFO",
342
format: "plain",
343
fileToRender: testDoc,
344
expectedOutputs: {
345
shouldContain: [infoHintText(testDoc)],
346
shouldNotContain: [debugHintText],
347
shouldSucceed: true
348
}
349
});
350
351
testLogDirectly({
352
testName: "WARNING should be equivalent to WARN level",
353
level: "WARNING",
354
format: "plain",
355
fileToRender: testDocWithError,
356
expectedOutputs: {
357
shouldContain: ["WARN:", "ERROR:"],
358
shouldNotContain: [debugHintText, infoHintText(testDocWithError)],
359
shouldSucceed: false
360
}
361
});
362
363
364
365
366