Failed runs end with one fatal line; failure lines are errors
check / check (push) Canceled after 0s

A command that exits 1 because files or albums failed now ends with one
fatal line saying how many failed and where to look, so -q never leaves
a non-zero exit unexplained: backup points to failures.json, backup-metadata
to mlDataError in each file's JSON, and fix-missing-thumbnails to its
Failed files list. The progress callbacks of the backup, the metadata
backup and the thumbnail helpers take a level, so every line that reports
a failure is logged at error. The library's operation names are shown in
plain words. The README says which lines are error and warning, and which
summary lines --json replaces.

Model: opus-5-5
This commit is contained in:
2026-10-18 19:15:16 +00:00
parent f9969b2525
commit d87a5e7ef5
12 changed files with 253 additions and 63 deletions
+18
View File
@@ -371,6 +371,24 @@ describe("quak backup with records it cannot decrypt", () => {
expect(stderr.text).toContain(" Failed: 2\n");
});
// The album is an error line. The last line is fatal and counts the files
// and the album, so `-q`, which prints fatal lines only, still says why
// the backup exits 1.
it("logs the album at error, and ends with one fatal line counting the files and the album", async () => {
const ctx = {
...context(brokenAccount()),
log: stderrLog(stderr, "error"),
};
expect(await backupCommand(ctx, backupDir(), {})).toBe(1);
expect(stderr.text).toMatch(
/^FAILED album 2: could not be decrypted: /,
);
expect(stderr.text.split("\n").at(-2)).toBe(
`quak: 2 file(s) and 1 album(s) failed; see ${join(backupDir(), "failures.json")} and the FAILED lines`,
);
});
it("names each album it cannot decrypt in the --json result", async () => {
expect(await backup(brokenAccount(), { json: true })).toBe(1);
+60
View File
@@ -913,6 +913,38 @@ describe("backup", () => {
);
});
// A failed file is an error line both where the backup fails it and in
// the list at the end. The last line is fatal, so `-q`, which prints
// fatal lines only, still says why the backup exits 1.
it("logs a failed file at error, and ends with one fatal line counting the failures", async () => {
const dir = join(root, "backup");
const last = `quak: 1 file(s) and 0 album(s) failed; see ${join(dir, "failures.json")} and the FAILED lines\n`;
const ctx = context(fakeClient({ failID: 101 }));
expect(
await backupCommand(
{ ...ctx, log: stderrLog(stderr, "error") },
dir,
{},
),
).toBe(1);
expect(stderr.text).toBe(
"FAILED original sunset.jpg: HTTP 500 from server\n" +
"\nFailed files:\n" +
" [Vacation] sunset.jpg (id 101): HTTP 500 from server\n" +
last,
);
stderr.text = "";
expect(
await backupCommand(
{ ...ctx, log: stderrLog(stderr, "fatal") },
dir,
{ json: true },
),
).toBe(1);
expect(stderr.text).toBe(last);
});
// `--json` shapes stdout only: the log lines stay on stderr, and the JSON
// takes the place of the summary.
it("prints the result as JSON with --json, still exiting 1 on a failure", async () => {
@@ -1303,6 +1335,34 @@ describe("helper list-missing-thumbnails", () => {
});
});
describe("helper fix-missing-thumbnails", () => {
// File 999 is in no album, so fixing it fails. The list of failed files
// is at error, and the last line is fatal, so `-q`, which prints fatal
// lines only, still says why the command exits 1.
it("lists a failed file at error, and ends with one fatal line counting the failures", async () => {
const last = `quak: 1 file(s) failed; see the "Failed files:" list, or the JSON with --json\n`;
const ctx = { ...context(), log: stderrLog(stderr, "error") };
expect(await fixMissingThumbnailsCommand(ctx, { file: ["999"] })).toBe(
1,
);
expect(stderr.text).toBe(
"\nFailed files:\n" +
" 999\tunknown\tfile not found in any collection\n" +
last,
);
stderr.text = "";
const quiet = { ...context(), log: stderrLog(stderr, "fatal") };
expect(
await fixMissingThumbnailsCommand(quiet, {
file: ["999"],
json: true,
}),
).toBe(1);
expect(stderr.text).toBe(last);
});
});
describe("backup-metadata --exif", () => {
// Runs the command and returns what it printed to stderr.
const backupMetadata = async (opts: { exif?: boolean; all?: boolean }) => {
+9 -6
View File
@@ -173,13 +173,13 @@ const FILES = "100\timage\tbeach.jpg\n";
// line for the one album whose files it fetched. Debug adds the start and end
// of the refresh, and trace each request with the status of its answer.
const INFO = "Fetched the files of 1 of 1 collections\n";
const DEBUG = "refresh started\n" + INFO + "refresh done\n";
const DEBUG = "Refresh started\n" + INFO + "Refresh done\n";
const TRACE =
"refresh started\n" +
"Refresh started\n" +
"GET https://api.ente.io/collections/v2?sinceTime=0: HTTP 200\n" +
"GET https://api.ente.io/collections/v2/diff?collectionID=1&sinceTime=0: HTTP 200\n" +
INFO +
"refresh done\n";
"Refresh done\n";
describe("the log level", () => {
it("is info by default", async () => {
@@ -326,6 +326,7 @@ describe("what the library reports", () => {
]);
});
// Each operation is named in plain words, as the README names it.
it("logs a refresh's progress at info, and its start and end at debug", () => {
const { log, lines } = collect();
const onProgress = logRefresh(log);
@@ -342,13 +343,15 @@ describe("what the library reports", () => {
status: "failed",
error: "HTTP 503",
});
onProgress({ operation: "fetchMLData", status: "started" });
onProgress({ operation: "fetchMLData", status: "done", fetched: 3 });
expect(lines).toEqual([
"debug: refresh started",
"debug: Refresh started",
"info: Fetched the files of 1 of 2 collections",
"debug: refresh failed: HTTP 503",
"debug: fetchMLData done",
"debug: Refresh failed: HTTP 503",
"debug: ML data fetch started",
"debug: ML data fetch done",
]);
});
});
+19 -9
View File
@@ -47,7 +47,7 @@ import {
type MetadataBackupOptions,
} from "../../src/metadata-backup.js";
import { backupMetadataCommand } from "../../src/cli-commands.js";
import { stderrLog } from "../../src/cli-log.js";
import { stderrLog, type LogLevel } from "../../src/cli-log.js";
import {
buildAccount,
buildMetaFetch,
@@ -491,9 +491,12 @@ describe("quak backup-metadata", () => {
});
describe("quak backup-metadata when a file's ML data cannot be read", () => {
// Run the CLI command against `fetch`, the mock unless given, and return
// its exit code, stderr and output directory.
const runCommand = async (fetch = buildMetaFetch(mock)) => {
// Run the CLI command against `fetch`, the mock unless given, logging at
// `level`, and return its exit code, stderr and output directory.
const runCommand = async (
fetch = buildMetaFetch(mock),
level: LogLevel = "info",
) => {
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
@@ -509,7 +512,7 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
stdout: { write: () => true },
log: stderrLog(
{ write: (text: string) => (stderr += text) },
"info",
level,
),
sessionDir: testDir,
cacheDir: mkdtempSync(join(testDir, "cache-")),
@@ -521,13 +524,16 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
return { code, stderr, outDir };
};
// The failed request is logged at error, so it is the first line printed
// at that level.
it("writes every file, marks the failed batch's files, and exits 1", async () => {
const { code, stderr, outDir } = await runCommand(
buildMetaFetch(mock, 200),
"error",
);
expect(code).toBe(1);
expect(stderr).toContain("ML data request for 1 file(s) failed");
expect(stderr).toMatch(/^ML data request for 1 file\(s\) failed: /);
const ok = JSON.parse(
readFileSync(
@@ -550,7 +556,9 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
});
// The server sends ML data for diagram.png encrypted under a key that is
// not the file's, so quak cannot decrypt it.
// not the file's, so quak cannot decrypt it. That is logged at error, and
// the last line is fatal, so `-q`, which prints fatal lines only, still
// says why the command exits 1.
it("writes the reason for ML data that does not decrypt, and exits 1", async () => {
const otherKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { header, ciphertext } = encryptBlob(
@@ -568,12 +576,14 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
},
},
}),
"error",
);
expect(code).toBe(1);
const reason = "secretstream chunk authentication failed";
expect(stderr).toContain(
`ML data of file 200 could not be read: ${reason}\n`,
expect(stderr).toBe(
`ML data of file 200 could not be read: ${reason}\n` +
`quak: the ML data of 1 file(s) could not be read; see mlDataError in their JSON in ${outDir}\n`,
);
const failed = JSON.parse(
readFileSync(