Failed runs end with one fatal line; failure lines are errors
check / check (push) Canceled after 0s
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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user