-v raises the CLI's log level per repeat; -q prints only fatal errors (closes #212)
check / check (push) Waiting to run
check / check (push) Waiting to run
Every line quak prints on stderr now goes through one log, in src/cli-log.ts, at one of six levels. The default, info, prints what quak printed before. -v adds each retry and the start and end of each refresh and ML data fetch; -vv adds each request with its result. -q prints only fatal lines, and is refused with -v; both are program options. Every non-zero exit logs one fatal line, so a run that exits 1 because files failed says how many and where to look. Every line reporting a failure is at error. --json changes stdout only, so backup and --json runs log their progress on stderr. withRetry and ApiClient report retries and requests through new callbacks. Model: opus-5-5
This commit was merged in pull request #216.
This commit is contained in:
@@ -54,6 +54,7 @@ import {
|
||||
saveSession,
|
||||
type CliContext,
|
||||
} from "../../src/cli-commands.js";
|
||||
import { stderrLog } from "../../src/cli-log.js";
|
||||
import { run } from "../../src/cli-run.js";
|
||||
import { loadSession } from "../../src/cli-session.js";
|
||||
import { Client } from "../../src/client.js";
|
||||
@@ -303,9 +304,10 @@ const backup = async (
|
||||
const prompted = async (): Promise<never> => {
|
||||
throw new Error("quak backup prompted");
|
||||
};
|
||||
const log = stderrLog(stderr, "info");
|
||||
const ctx: CliContext = {
|
||||
stdout,
|
||||
stderr,
|
||||
log,
|
||||
sessionDir,
|
||||
cacheDir: `${dir}-cache`,
|
||||
loadSession: (path) =>
|
||||
@@ -319,7 +321,7 @@ const backup = async (
|
||||
promptSecret: prompted,
|
||||
};
|
||||
const code = await new Promise<number>((resolve) => {
|
||||
void run(backupCommand(ctx, dir, opts), stdout, stderr, resolve);
|
||||
void run(backupCommand(ctx, dir, opts), stdout, stderr, log, resolve);
|
||||
});
|
||||
return { code, stderr: printed, stdout: output };
|
||||
};
|
||||
@@ -1104,6 +1106,9 @@ describe("a backup whose lock is lost", () => {
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toBe(
|
||||
"Starting backup...\n" +
|
||||
"Fetched the files of 1 of 3 collections\n" +
|
||||
"Fetched the files of 2 of 3 collections\n" +
|
||||
"Fetched the files of 3 of 3 collections\n" +
|
||||
"Refreshing library...\n" +
|
||||
"Fetching original beach.jpg (100)...\n" +
|
||||
"Fetching original IMG_0002.HEIC (300)...\n" +
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
saveSession,
|
||||
type CliContext,
|
||||
} from "../../src/cli-commands.js";
|
||||
import { stderrLog } from "../../src/cli-log.js";
|
||||
import { loadSession } from "../../src/cli-session.js";
|
||||
import { Client, type ClientSnapshot } from "../../src/client.js";
|
||||
import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js";
|
||||
@@ -304,7 +305,7 @@ const context = (ente: FakeEnte): CliContext => {
|
||||
saveSession(join(root, "session"), ente.snapshot());
|
||||
return {
|
||||
stdout,
|
||||
stderr,
|
||||
log: stderrLog(stderr, "info"),
|
||||
sessionDir: join(root, "session"),
|
||||
cacheDir: join(root, "cache"),
|
||||
loadSession: (path) =>
|
||||
@@ -370,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);
|
||||
|
||||
|
||||
+11
-3
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Tests for the retry options `bin/quak.ts` loads the saved session with
|
||||
* (`quak backup` gets the unattended ones, every other command the default),
|
||||
* and for `quak files` taking its collection ID as an optional argument.
|
||||
* each with the callbacks that log its requests and retries, and for
|
||||
* `quak files` taking its collection ID as an optional argument.
|
||||
*
|
||||
* Each test runs `bin/quak.ts`, as the smoke test does, with its session loader
|
||||
* replaced by one that records the options it is given and reports no session,
|
||||
@@ -65,14 +66,21 @@ describe("bin/quak.ts session loading", () => {
|
||||
attempts,
|
||||
baseDelayMs,
|
||||
maxDelayMs,
|
||||
onRetry: expect.any(Function),
|
||||
}),
|
||||
onRequest: expect.any(Function),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("loads the session for another command with the default options", async () => {
|
||||
it("loads the session for another command with the default retry options", async () => {
|
||||
await quak("collections");
|
||||
expect(loaded).toEqual([undefined]);
|
||||
expect(loaded).toEqual([
|
||||
{
|
||||
retry: { onRetry: expect.any(Function) },
|
||||
onRequest: expect.any(Function),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+107
-9
@@ -51,6 +51,7 @@ import {
|
||||
listMissingThumbnailsCommand,
|
||||
fixMissingThumbnailsCommand,
|
||||
} from "../../src/cli-commands.js";
|
||||
import { stderrLog } from "../../src/cli-log.js";
|
||||
import { run } from "../../src/cli-run.js";
|
||||
import { loadSession } from "../../src/cli-session.js";
|
||||
import {
|
||||
@@ -208,9 +209,11 @@ let root: string;
|
||||
let stdout: Output;
|
||||
let stderr: Output;
|
||||
|
||||
// The commands log at the default level, info, as `quak` without `-v` or `-q`
|
||||
// does; the levels have their own tests, in `log.test.ts`.
|
||||
const context = (client: Client | null = fakeClient()): CliContext => ({
|
||||
stdout,
|
||||
stderr,
|
||||
log: stderrLog(stderr, "info"),
|
||||
sessionDir: join(root, "session"),
|
||||
cacheDir: join(root, "cache"),
|
||||
loadSession: () => client,
|
||||
@@ -566,7 +569,7 @@ describe("files", () => {
|
||||
[101, 1],
|
||||
[200, 2],
|
||||
]);
|
||||
expect(stderr.text).toBe("");
|
||||
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -910,6 +913,40 @@ 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 () => {
|
||||
const ctx = context(fakeClient({ failID: 101 }));
|
||||
const code = await backupCommand(ctx, join(root, "backup"), {
|
||||
@@ -924,7 +961,31 @@ describe("backup", () => {
|
||||
failed: 1,
|
||||
});
|
||||
expect(result.errors[0].fileID).toBe(101);
|
||||
expect(stderr.text).toBe("Starting backup...\n");
|
||||
expect(stderr.text).toContain(
|
||||
"Fetching original sunset.jpg (101)...\n",
|
||||
);
|
||||
expect(stderr.text).not.toContain("Backup complete");
|
||||
});
|
||||
|
||||
// On an empty cache the library's first refresh fetches the files of
|
||||
// every album before the backup can start, which on a large account takes
|
||||
// a while; each album it has fetched the files of is counted on stderr,
|
||||
// with `--json` too.
|
||||
it("counts on stderr each album whose files its first refresh fetched, with --json too", async () => {
|
||||
const start =
|
||||
"Starting backup...\n" +
|
||||
FETCHED_BOTH_ALBUMS +
|
||||
"Refreshing library...\n";
|
||||
for (const json of [false, true]) {
|
||||
stderr.text = "";
|
||||
const dir = mkdtempSync(join(root, "backup-"));
|
||||
const ctx = {
|
||||
...context(),
|
||||
cacheDir: mkdtempSync(join(root, "cache-")),
|
||||
};
|
||||
expect(await backupCommand(ctx, dir, { json })).toBe(0);
|
||||
expect(stderr.text.slice(0, start.length)).toBe(start);
|
||||
}
|
||||
});
|
||||
|
||||
it("--verify downloads again an original that does not match its hash, prints the counts, and exits 0", async () => {
|
||||
@@ -1014,6 +1075,7 @@ describe("backup", () => {
|
||||
backupCommand(ctx, dir, {}),
|
||||
new PassThrough(),
|
||||
runStderr,
|
||||
stderrLog(runStderr, "info"),
|
||||
resolve,
|
||||
);
|
||||
});
|
||||
@@ -1254,7 +1316,9 @@ describe("helper list-missing-thumbnails", () => {
|
||||
expect(stderr.text).toContain("No missing thumbnails found.\n");
|
||||
});
|
||||
|
||||
it("prints a JSON array with --json and no progress", async () => {
|
||||
// `--json` shapes stdout only: the progress stays on stderr, and the JSON
|
||||
// takes the place of the count and the list.
|
||||
it("prints a JSON array with --json, and its progress on stderr", async () => {
|
||||
const ctx = context(fakeClient({ emptyThumbID: 200 }));
|
||||
expect(await listMissingThumbnailsCommand(ctx, { json: true })).toBe(0);
|
||||
expect(JSON.parse(stdout.text)).toEqual([
|
||||
@@ -1265,7 +1329,37 @@ describe("helper list-missing-thumbnails", () => {
|
||||
reason: "empty thumbnail (0 bytes)",
|
||||
},
|
||||
]);
|
||||
expect(stderr.text).toBe("");
|
||||
expect(stderr.text).toContain(FETCHED_BOTH_ALBUMS);
|
||||
expect(stderr.text).toContain("[Work] Checking thumbnails...\n");
|
||||
expect(stderr.text).not.toContain("with 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1624,9 +1718,9 @@ describe("an account with many albums", () => {
|
||||
expect(stderr.text).toBe(progress);
|
||||
});
|
||||
|
||||
// With `--json` the refresh's progress is not printed, as `backup` and
|
||||
// the thumbnail helpers print none of theirs.
|
||||
it("files --json prints no progress", async () => {
|
||||
// `--json` shapes stdout only, so the refresh's progress is still counted
|
||||
// on stderr.
|
||||
it("files --json counts on stderr each album whose files its refresh fetched", async () => {
|
||||
expect(await filesCommand(accountContext(), "7", { json: true })).toBe(
|
||||
0,
|
||||
);
|
||||
@@ -1640,6 +1734,10 @@ describe("an account with many albums", () => {
|
||||
collectionID: 7,
|
||||
},
|
||||
]);
|
||||
expect(stderr.text).toBe("");
|
||||
let progress = "";
|
||||
for (let n = 1; n <= ALBUMS; n++) {
|
||||
progress += `Fetched the files of ${n} of ${ALBUMS} collections\n`;
|
||||
}
|
||||
expect(stderr.text).toBe(progress);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Tests for the CLI's log levels, set with `-v`/`--verbose` and `-q`/`--quiet`
|
||||
* (`src/cli-log.ts`, issue #212).
|
||||
*
|
||||
* Every line quak prints on stderr is a log line at one of six levels, from
|
||||
* the quietest: fatal, error, warning, info, debug, trace. A run prints the
|
||||
* lines at its own level and at every quieter one. The level is info unless
|
||||
* asked otherwise: `-v` raises it to debug, `-vv` to trace, and more repeats
|
||||
* stay at trace. `-q` lowers it to fatal, so a run prints only the line a
|
||||
* command prints as it fails with a non-zero exit code. stdout holds the
|
||||
* command's output at every level.
|
||||
*
|
||||
* Most tests run `bin/quak.ts`, as the smoke test does, against an account of
|
||||
* one album holding one photo: the saved session is in a temporary directory,
|
||||
* and `fetch` is a fake of the Ente server that serves the album and the photo
|
||||
* encrypted as Ente's clients encrypt them. Each run has a cache directory of
|
||||
* its own, so its refresh fetches the album's files, as on a first run.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import sodium from "libsodium-wrappers-sumo";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
import { ApiClient } from "../../src/api/client.js";
|
||||
import { saveSession } from "../../src/cli-commands.js";
|
||||
import { logRefresh, logRequests, type Log } from "../../src/cli-log.js";
|
||||
import { init, toBase64 } from "../../src/crypto/index.js";
|
||||
import { encryptSecretbox, encryptStreamBlob } from "../ente-server.js";
|
||||
|
||||
// `bin/quak.ts` reads the saved session from the data directory env-paths
|
||||
// gives it, which here is a temporary directory made before the first run.
|
||||
const paths = vi.hoisted(() => ({ data: "" }));
|
||||
vi.mock("env-paths", () => ({ default: () => paths }));
|
||||
|
||||
const USER_ID = 42;
|
||||
const root = mkdtempSync(join(tmpdir(), "quak-log-test-"));
|
||||
const argv = process.argv;
|
||||
|
||||
class ExitCalled extends Error {}
|
||||
|
||||
// Run `quak <args>` to its exit, with a cache of its own, and return its exit
|
||||
// code and what it wrote on stdout and on stderr, which are kept off the
|
||||
// terminal. `process.exit` is stubbed to throw, so nothing runs after it, as
|
||||
// nothing does after a real exit.
|
||||
const quak = async (
|
||||
...args: string[]
|
||||
): Promise<{ code: number; stdout: string; stderr: string }> => {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let code = NaN;
|
||||
vi.spyOn(process.stdout, "write").mockImplementation((text) => {
|
||||
stdout += String(text);
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(process.stderr, "write").mockImplementation((text) => {
|
||||
stderr += String(text);
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(process, "exit").mockImplementation((exitCode) => {
|
||||
code = Number(exitCode ?? 0);
|
||||
throw new ExitCalled();
|
||||
});
|
||||
const cache = mkdtempSync(join(root, "cache-"));
|
||||
process.argv = ["node", "quak", "--cache-dir", cache, ...args];
|
||||
vi.resetModules();
|
||||
await expect(import("../../bin/quak.js")).rejects.toBeInstanceOf(
|
||||
ExitCalled,
|
||||
);
|
||||
return { code, stdout, stderr };
|
||||
};
|
||||
|
||||
// Album 1, "Trips", of user 42, holding photo 100, "beach.jpg". The album's
|
||||
// key is encrypted with the account's master key, the photo's key with the
|
||||
// album's, and the photo's metadata with its own key. Only the session and
|
||||
// these two records are needed: the tests never log in.
|
||||
beforeAll(async () => {
|
||||
await init();
|
||||
await sodium.ready;
|
||||
const masterKey = sodium.crypto_secretbox_keygen();
|
||||
const keyPair = sodium.crypto_box_keypair();
|
||||
paths.data = join(root, "session");
|
||||
saveSession(paths.data, {
|
||||
email: "log@example.com",
|
||||
userID: USER_ID,
|
||||
token: "token",
|
||||
masterKey: toBase64(masterKey),
|
||||
secretKey: toBase64(keyPair.privateKey),
|
||||
publicKey: toBase64(keyPair.publicKey),
|
||||
});
|
||||
|
||||
const albumKey = sodium.crypto_secretbox_keygen();
|
||||
const albumKeyBox = encryptSecretbox(albumKey, masterKey);
|
||||
const name = encryptSecretbox(new TextEncoder().encode("Trips"), albumKey);
|
||||
const album = {
|
||||
id: 1,
|
||||
owner: { id: USER_ID },
|
||||
encryptedKey: toBase64(albumKeyBox.ciphertext),
|
||||
keyDecryptionNonce: toBase64(albumKeyBox.nonce),
|
||||
encryptedName: toBase64(name.ciphertext),
|
||||
nameDecryptionNonce: toBase64(name.nonce),
|
||||
type: "album",
|
||||
updationTime: 1,
|
||||
};
|
||||
const photoKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||
const photoKeyBox = encryptSecretbox(photoKey, albumKey);
|
||||
const metadata = encryptStreamBlob(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
title: "beach.jpg",
|
||||
fileType: 0,
|
||||
creationTime: 1000,
|
||||
modificationTime: 1000,
|
||||
}),
|
||||
),
|
||||
photoKey,
|
||||
);
|
||||
const photo = {
|
||||
id: 100,
|
||||
collectionID: 1,
|
||||
ownerID: USER_ID,
|
||||
encryptedKey: toBase64(photoKeyBox.ciphertext),
|
||||
keyDecryptionNonce: toBase64(photoKeyBox.nonce),
|
||||
metadata: {
|
||||
encryptedData: toBase64(metadata.ciphertext),
|
||||
decryptionHeader: toBase64(metadata.header),
|
||||
},
|
||||
// These tests fetch no original and no thumbnail.
|
||||
file: { decryptionHeader: "" },
|
||||
thumbnail: { decryptionHeader: "" },
|
||||
updationTime: 1,
|
||||
};
|
||||
|
||||
const json = (body: unknown): Response =>
|
||||
new Response(JSON.stringify(body), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
vi.stubGlobal("fetch", async (input: string) => {
|
||||
const { pathname } = new URL(input);
|
||||
if (pathname === "/collections/v2") {
|
||||
return json({ collections: [album] });
|
||||
}
|
||||
if (pathname === "/collections/v2/diff") {
|
||||
return json({ diff: [photo], hasMore: false });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.argv = argv;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// What `quak files` writes on stdout at every level.
|
||||
const FILES = "100\timage\tbeach.jpg\n";
|
||||
|
||||
// What it writes on stderr at each level. Info is the refresh's progress, one
|
||||
// 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 TRACE =
|
||||
"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";
|
||||
|
||||
describe("the log level", () => {
|
||||
it("is info by default", async () => {
|
||||
expect(await quak("files")).toEqual({
|
||||
code: 0,
|
||||
stdout: FILES,
|
||||
stderr: INFO,
|
||||
});
|
||||
});
|
||||
|
||||
it("is debug with -v", async () => {
|
||||
expect(await quak("-v", "files")).toEqual({
|
||||
code: 0,
|
||||
stdout: FILES,
|
||||
stderr: DEBUG,
|
||||
});
|
||||
});
|
||||
|
||||
it("is trace with -vv", async () => {
|
||||
expect(await quak("-vv", "files")).toEqual({
|
||||
code: 0,
|
||||
stdout: FILES,
|
||||
stderr: TRACE,
|
||||
});
|
||||
});
|
||||
|
||||
it("stays at trace with -vvv", async () => {
|
||||
expect((await quak("-vvv", "files")).stderr).toBe(TRACE);
|
||||
});
|
||||
|
||||
it("counts --verbose --verbose as -vv", async () => {
|
||||
expect((await quak("--verbose", "--verbose", "files")).stderr).toBe(
|
||||
TRACE,
|
||||
);
|
||||
});
|
||||
|
||||
// Both are options of the program, taken before or after the command's
|
||||
// name, as `--cache-dir` is.
|
||||
it("is set by an option after the command's name too", async () => {
|
||||
expect((await quak("files", "-v")).stderr).toBe(DEBUG);
|
||||
expect((await quak("files", "--json", "-vv")).stderr).toBe(TRACE);
|
||||
expect((await quak("files", "-q")).stderr).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("-q", () => {
|
||||
it("prints nothing on stderr when the command works", async () => {
|
||||
expect(await quak("-q", "files")).toEqual({
|
||||
code: 0,
|
||||
stdout: FILES,
|
||||
stderr: "",
|
||||
});
|
||||
});
|
||||
|
||||
// The refresh's progress is left out; the line saying why the command
|
||||
// failed is the fatal one, and it is printed.
|
||||
it("prints only the line a failing command exits with", async () => {
|
||||
expect(await quak("-q", "files", "9")).toEqual({
|
||||
code: 1,
|
||||
stdout: "",
|
||||
stderr: "Collection 9 not found\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("is refused with -v, in one line, before the command runs", async () => {
|
||||
for (const args of [
|
||||
["-q", "-v", "files"],
|
||||
["files", "--verbose", "--quiet"],
|
||||
]) {
|
||||
const { code, stdout, stderr } = await quak(...args);
|
||||
expect(code).toBe(1);
|
||||
expect(stdout).toBe("");
|
||||
expect(stderr).toBe(
|
||||
"error: option '-q, --quiet' cannot be used with option '-v, --verbose'\n",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// `--help` lists both options, and each command's help lists them too, since
|
||||
// each command takes them.
|
||||
describe("--help", () => {
|
||||
it("lists -v and -q for the program and for each command", async () => {
|
||||
for (const args of [["--help"], ["files", "--help"]]) {
|
||||
const { stdout } = await quak(...args);
|
||||
expect(stdout).toContain("-v, --verbose");
|
||||
expect(stdout).toContain("-q, --quiet");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// The library writes no lines itself. Its layers report through callbacks,
|
||||
// and the CLI logs what they report.
|
||||
describe("what the library reports", () => {
|
||||
const collect = (): { log: Log; lines: string[] } => {
|
||||
const lines: string[] = [];
|
||||
return { log: (level, line) => lines.push(`${level}: ${line}`), lines };
|
||||
};
|
||||
|
||||
// A request answered 503 and retried: each attempt is a request, logged
|
||||
// at trace with its status, and the retry is logged at debug with how
|
||||
// long it waits and why.
|
||||
it("logs each request at trace and each retry at debug", async () => {
|
||||
const { log, lines } = collect();
|
||||
const answers = [
|
||||
new Response("unavailable", { status: 503 }),
|
||||
new Response("{}", {
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
];
|
||||
const client = new ApiClient({
|
||||
...logRequests(log, {
|
||||
sleep: async () => {},
|
||||
random: () => 0.5,
|
||||
}),
|
||||
fetch: async () => answers.shift()!,
|
||||
});
|
||||
|
||||
await client.getJSON("/collections/v2", { sinceTime: 0 });
|
||||
|
||||
const url = "https://api.ente.io/collections/v2?sinceTime=0";
|
||||
expect(lines).toEqual([
|
||||
`trace: GET ${url}: HTTP 503`,
|
||||
"debug: Attempt 1 failed, retrying in 250 ms: HTTP 503",
|
||||
`trace: GET ${url}: HTTP 200`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("logs a request that failed with its error", async () => {
|
||||
const { log, lines } = collect();
|
||||
const client = new ApiClient({
|
||||
...logRequests(log, { attempts: 1 }),
|
||||
fetch: async () => {
|
||||
throw new TypeError("fetch failed", {
|
||||
cause: new Error("connect ECONNREFUSED 1.2.3.4:443"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await expect(client.getJSON("/collections/v2")).rejects.toThrow();
|
||||
|
||||
expect(lines).toEqual([
|
||||
"trace: GET https://api.ente.io/collections/v2: fetch failed: connect ECONNREFUSED 1.2.3.4:443",
|
||||
]);
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
onProgress({ operation: "refresh", status: "started" });
|
||||
onProgress({
|
||||
operation: "refresh",
|
||||
status: "progress",
|
||||
fetched: 1,
|
||||
total: 2,
|
||||
});
|
||||
onProgress({
|
||||
operation: "refresh",
|
||||
status: "failed",
|
||||
error: "HTTP 503",
|
||||
});
|
||||
onProgress({ operation: "fetchMLData", status: "started" });
|
||||
onProgress({ operation: "fetchMLData", status: "done", fetched: 3 });
|
||||
|
||||
expect(lines).toEqual([
|
||||
"debug: Refresh started",
|
||||
"info: Fetched the files of 1 of 2 collections",
|
||||
"debug: Refresh failed: HTTP 503",
|
||||
"debug: ML data fetch started",
|
||||
"debug: ML data fetch done",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
type MetadataBackupOptions,
|
||||
} from "../../src/metadata-backup.js";
|
||||
import { backupMetadataCommand } from "../../src/cli-commands.js";
|
||||
import { stderrLog, type LogLevel } from "../../src/cli-log.js";
|
||||
import {
|
||||
buildAccount,
|
||||
buildMetaFetch,
|
||||
@@ -490,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,
|
||||
@@ -506,7 +510,10 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
|
||||
const code = await backupMetadataCommand(
|
||||
{
|
||||
stdout: { write: () => true },
|
||||
stderr: { write: (text: string) => (stderr += text) },
|
||||
log: stderrLog(
|
||||
{ write: (text: string) => (stderr += text) },
|
||||
level,
|
||||
),
|
||||
sessionDir: testDir,
|
||||
cacheDir: mkdtempSync(join(testDir, "cache-")),
|
||||
loadSession: () => client,
|
||||
@@ -517,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(
|
||||
@@ -546,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(
|
||||
@@ -564,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(
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PassThrough } from "node:stream";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { ApiError } from "../../src/api/client.js";
|
||||
import { stderrLog } from "../../src/cli-log.js";
|
||||
import { run } from "../../src/cli-run.js";
|
||||
|
||||
// A stream whose written text is kept in `text`; writes finish at once, so
|
||||
@@ -23,7 +24,13 @@ const runToExit = async (
|
||||
const stdout = collector();
|
||||
const stderr = collector();
|
||||
const code = await new Promise<number>((resolve) => {
|
||||
void run(command, stdout.stream, stderr.stream, resolve);
|
||||
void run(
|
||||
command,
|
||||
stdout.stream,
|
||||
stderr.stream,
|
||||
stderrLog(stderr.stream, "info"),
|
||||
resolve,
|
||||
);
|
||||
});
|
||||
return { code, stdout: stdout.text(), stderr: stderr.text() };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user