Fix two intermittently failing library tests (closes #90) #92
@@ -73,7 +73,7 @@ if (photo) {
|
|||||||
console.log(`original at ${path}`);
|
console.log(`original at ${path}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
lib.close();
|
await lib.close();
|
||||||
```
|
```
|
||||||
|
|
||||||
The lower-level `Client` (login, session serialization, and the raw
|
The lower-level `Client` (login, session serialization, and the raw
|
||||||
@@ -563,7 +563,10 @@ and pass it. The three pools default to 10 / 5 / 25 (see Request pools below).
|
|||||||
`lib.status()` returns a `LibraryStatus` (collection/file counts, last
|
`lib.status()` returns a `LibraryStatus` (collection/file counts, last
|
||||||
refresh/ML times and errors, originals usage and effective limit, precache
|
refresh/ML times and errors, originals usage and effective limit, precache
|
||||||
progress, and `closed`). `lib.close()` stops the background timer; it is
|
progress, and `closed`). `lib.close()` stops the background timer; it is
|
||||||
idempotent, and an in-flight refresh is left to finish.
|
idempotent, and an in-flight refresh is left to finish. The promise it returns
|
||||||
|
resolves once that refresh (including its cache write), the ML data fetch and
|
||||||
|
the precache fetches already running have all finished, so the cache directory
|
||||||
|
can then be removed.
|
||||||
|
|
||||||
### Default reads vs. fresh reads
|
### Default reads vs. fresh reads
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ Tag v1.0.0.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-09-23: Fixed two intermittently failing library tests (issue 90).
|
||||||
|
`Library.close()` now returns a promise that resolves once an in-flight
|
||||||
|
refresh (including its cache write), the ML data fetch and running precache
|
||||||
|
sweeps have finished; the library tests await it, so `afterEach` no longer
|
||||||
|
removes the cache directory while something is still writing into it. The
|
||||||
|
precache test waits for both fills to report "done" instead of for its stub
|
||||||
|
source to be called, which happened before the cache recorded the file.
|
||||||
- 2026-09-23: Pinned three guards reviewers found untested (issue 89). The
|
- 2026-09-23: Pinned three guards reviewers found untested (issue 89). The
|
||||||
download idle deadline's timer is unref'd, so it can never keep the process
|
download idle deadline's timer is unref'd, so it can never keep the process
|
||||||
alive, and a test checks no timer is left after a download completes or fails.
|
alive, and a test checks no timer is left after a download completes or fails.
|
||||||
|
|||||||
+8
-8
@@ -194,7 +194,7 @@ export const collectionsCommand = async (
|
|||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -235,7 +235,7 @@ export const filesCommand = async (
|
|||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -272,7 +272,7 @@ export const getCommand = async (
|
|||||||
ctx.stderr.write(`${result.bytes} bytes -> ${outPath}\n`);
|
ctx.stderr.write(`${result.bytes} bytes -> ${outPath}\n`);
|
||||||
return 0;
|
return 0;
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -309,7 +309,7 @@ export const getThumbCommand = async (
|
|||||||
ctx.stderr.write(`${result.bytes} bytes -> ${outPath}\n`);
|
ctx.stderr.write(`${result.bytes} bytes -> ${outPath}\n`);
|
||||||
return 0;
|
return 0;
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -329,7 +329,7 @@ export const backupMetadataCommand = async (
|
|||||||
});
|
});
|
||||||
return 0;
|
return 0;
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -376,7 +376,7 @@ export const backupCommand = async (
|
|||||||
|
|
||||||
return result.failed > 0 ? 1 : 0;
|
return result.failed > 0 ? 1 : 0;
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -411,7 +411,7 @@ export const listMissingThumbnailsCommand = async (
|
|||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -481,6 +481,6 @@ export const fixMissingThumbnailsCommand = async (
|
|||||||
|
|
||||||
return results.some((r) => r.status === "failed") ? 1 : 0;
|
return results.some((r) => r.status === "failed") ? 1 : 0;
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+17
-10
@@ -266,8 +266,9 @@ export class Library {
|
|||||||
// that, a fresh read propagates it.
|
// that, a fresh read propagates it.
|
||||||
private cycle?: Promise<void>;
|
private cycle?: Promise<void>;
|
||||||
// Guards the ML fetch pass so a slow backfill never runs twice at once; a
|
// Guards the ML fetch pass so a slow backfill never runs twice at once; a
|
||||||
// refresh whose pass is still running kicks nothing new.
|
// refresh whose pass is still running kicks nothing new. Holds the running
|
||||||
private mlFetching = false;
|
// pass, so `close()` can wait for it.
|
||||||
|
private mlFetch?: Promise<void>;
|
||||||
private closed = false;
|
private closed = false;
|
||||||
private lastRefreshAt?: number;
|
private lastRefreshAt?: number;
|
||||||
private lastError?: string;
|
private lastError?: string;
|
||||||
@@ -560,14 +561,21 @@ export class Library {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Stop the background timer. Idempotent. An in-flight refresh is left to
|
// Stop the background timer. Idempotent. An in-flight refresh is left to
|
||||||
// finish; it will not schedule another cycle once closed.
|
// finish; it will not schedule another cycle once closed. The returned
|
||||||
close(): void {
|
// promise resolves once that refresh (including its cache write), the ML
|
||||||
|
// fetch pass and the precache fetches already running have all finished,
|
||||||
|
// so a caller can then remove the cache directory. A refresh failure is
|
||||||
|
// reported through `status()`, not thrown here.
|
||||||
|
async close(): Promise<void> {
|
||||||
this.closed = true;
|
this.closed = true;
|
||||||
this.precache?.close();
|
const precacheClosed = this.precache?.close();
|
||||||
if (this.timer !== undefined) {
|
if (this.timer !== undefined) {
|
||||||
clearTimeout(this.timer);
|
clearTimeout(this.timer);
|
||||||
this.timer = undefined;
|
this.timer = undefined;
|
||||||
}
|
}
|
||||||
|
await this.cycle?.catch(() => {});
|
||||||
|
await this.mlFetch;
|
||||||
|
await precacheClosed;
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleNext(): void {
|
private scheduleNext(): void {
|
||||||
@@ -631,7 +639,9 @@ export class Library {
|
|||||||
// outside the refresh's success/failure so a fetch or disk problem
|
// outside the refresh's success/failure so a fetch or disk problem
|
||||||
// there never marks the metadata refresh failed, and it is not
|
// there never marks the metadata refresh failed, and it is not
|
||||||
// awaited so it never stalls the refresh interval.
|
// awaited so it never stalls the refresh interval.
|
||||||
void this.runMLFetch();
|
this.mlFetch ??= this.runMLFetch().finally(() => {
|
||||||
|
this.mlFetch = undefined;
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err instanceof Error ? err.message : String(err);
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
this.lastError = error;
|
this.lastError = error;
|
||||||
@@ -745,13 +755,12 @@ export class Library {
|
|||||||
// Bind so the call keeps the client as its receiver when invoked
|
// Bind so the call keeps the client as its receiver when invoked
|
||||||
// through the pool below.
|
// through the pool below.
|
||||||
const fetchMLData = this.client.fetchMLData?.bind(this.client);
|
const fetchMLData = this.client.fetchMLData?.bind(this.client);
|
||||||
if (!mldata || !fetchMLData || this.closed || this.mlFetching) return;
|
if (!mldata || !fetchMLData || this.closed) return;
|
||||||
|
|
||||||
const files = this.uniqueFiles();
|
const files = this.uniqueFiles();
|
||||||
const needed = mldata.neededFor(files);
|
const needed = mldata.neededFor(files);
|
||||||
if (needed.length === 0) return;
|
if (needed.length === 0) return;
|
||||||
|
|
||||||
this.mlFetching = true;
|
|
||||||
this.emit({ operation: "fetchMLData", status: "started" });
|
this.emit({ operation: "fetchMLData", status: "started" });
|
||||||
try {
|
try {
|
||||||
const fileKeys = new Map<number, Uint8Array>();
|
const fileKeys = new Map<number, Uint8Array>();
|
||||||
@@ -784,8 +793,6 @@ export class Library {
|
|||||||
const error = err instanceof Error ? err.message : String(err);
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
this.lastMLError = error;
|
this.lastMLError = error;
|
||||||
this.emit({ operation: "fetchMLData", status: "failed", error });
|
this.emit({ operation: "fetchMLData", status: "failed", error });
|
||||||
} finally {
|
|
||||||
this.mlFetching = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-12
@@ -92,9 +92,10 @@ export class Precache {
|
|||||||
private pinned = new Set<number>();
|
private pinned = new Set<number>();
|
||||||
|
|
||||||
// A sweep runs at most once per fill at a time; a re-kick while one runs is
|
// A sweep runs at most once per fill at a time; a re-kick while one runs is
|
||||||
// a no-op, and the next refresh re-kicks after it finishes.
|
// a no-op, and the next refresh re-kicks after it finishes. Each holds the
|
||||||
private thumbRunning = false;
|
// running sweep, so `close()` can wait for it.
|
||||||
private originalsRunning = false;
|
private thumbSweep?: Promise<void>;
|
||||||
|
private originalsSweep?: Promise<void>;
|
||||||
private readonly aborter = new AbortController();
|
private readonly aborter = new AbortController();
|
||||||
private closed = false;
|
private closed = false;
|
||||||
|
|
||||||
@@ -191,15 +192,19 @@ export class Precache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Stop the fills. In-flight fetches are left to settle; queued ones drop.
|
// Stop the fills. In-flight fetches are left to settle; queued ones drop.
|
||||||
close(): void {
|
// Resolves once both sweeps have finished, so nothing is still writing.
|
||||||
|
async close(): Promise<void> {
|
||||||
this.closed = true;
|
this.closed = true;
|
||||||
this.aborter.abort();
|
this.aborter.abort();
|
||||||
|
await Promise.all([
|
||||||
|
this.thumbSweep?.catch(() => {}),
|
||||||
|
this.originalsSweep?.catch(() => {}),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private kickThumbnails(): void {
|
private kickThumbnails(): void {
|
||||||
if (this.thumbRunning) return;
|
if (this.thumbSweep) return;
|
||||||
this.thumbRunning = true;
|
this.thumbSweep = this.sweep(
|
||||||
void this.sweep(
|
|
||||||
"precacheThumbnails",
|
"precacheThumbnails",
|
||||||
() => this.thumbOrder,
|
() => this.thumbOrder,
|
||||||
(id) => this.cache!.pathsFor(id).thumbnailPath !== undefined,
|
(id) => this.cache!.pathsFor(id).thumbnailPath !== undefined,
|
||||||
@@ -211,14 +216,13 @@ export class Precache {
|
|||||||
signal: this.aborter.signal,
|
signal: this.aborter.signal,
|
||||||
}),
|
}),
|
||||||
).finally(() => {
|
).finally(() => {
|
||||||
this.thumbRunning = false;
|
this.thumbSweep = undefined;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private kickOriginals(): void {
|
private kickOriginals(): void {
|
||||||
if (this.originalsRunning) return;
|
if (this.originalsSweep) return;
|
||||||
this.originalsRunning = true;
|
this.originalsSweep = this.sweep(
|
||||||
void this.sweep(
|
|
||||||
"precacheOriginals",
|
"precacheOriginals",
|
||||||
() => this.originalsOrder,
|
() => this.originalsOrder,
|
||||||
(id) => this.cache!.pathsFor(id).originalPath !== undefined,
|
(id) => this.cache!.pathsFor(id).originalPath !== undefined,
|
||||||
@@ -229,7 +233,7 @@ export class Precache {
|
|||||||
signal: this.aborter.signal,
|
signal: this.aborter.signal,
|
||||||
}),
|
}),
|
||||||
).finally(() => {
|
).finally(() => {
|
||||||
this.originalsRunning = false;
|
this.originalsSweep = undefined;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ describe("Library content wiring", () => {
|
|||||||
expect(lib.photos.byID({ fileID: 1 })!.record().thumbnailPath).toBe(
|
expect(lib.photos.byID({ fileID: 1 })!.record().thumbnailPath).toBe(
|
||||||
result.path,
|
result.path,
|
||||||
);
|
);
|
||||||
lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drives thumbnails.ensure through the cache", async () => {
|
it("drives thumbnails.ensure through the cache", async () => {
|
||||||
@@ -139,7 +139,7 @@ describe("Library content wiring", () => {
|
|||||||
expect(results).toEqual([
|
expect(results).toEqual([
|
||||||
{ fileID: 1, path: join(root, "cache", "thumbnails", "1.jpg") },
|
{ fileID: 1, path: join(root, "cache", "thumbnails", "1.jpg") },
|
||||||
]);
|
]);
|
||||||
lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws from content methods when opened without a content source", async () => {
|
it("throws from content methods when opened without a content source", async () => {
|
||||||
@@ -155,6 +155,6 @@ describe("Library content wiring", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
lib.thumbnails.ensure({ fileIDs: [1], priority: "visible" }),
|
lib.thumbnails.ensure({ fileIDs: [1], priority: "visible" }),
|
||||||
).rejects.toThrow(/content cache/i);
|
).rejects.toThrow(/content cache/i);
|
||||||
lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ describe("Library.fresh", () => {
|
|||||||
// And the change is now live for the default namespaces too.
|
// And the change is now live for the default namespaces too.
|
||||||
expect(lib.photos.byID({ fileID: 1002 })?.fileID).toBe(1002);
|
expect(lib.photos.byID({ fileID: 1002 })?.fileID).toBe(1002);
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -228,7 +228,7 @@ describe("Library.fresh", () => {
|
|||||||
expect(reads.photos.byID({ fileID: 1002 })?.fileID).toBe(1002);
|
expect(reads.photos.byID({ fileID: 1002 })?.fileID).toBe(1002);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -266,7 +266,7 @@ describe("Library.fresh", () => {
|
|||||||
);
|
);
|
||||||
expect(lib.status().lastError).toBeUndefined();
|
expect(lib.status().lastError).toBeUndefined();
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
* failure surfaces via `onProgress` ("failed") and `status()`, and a later
|
* failure surfaces via `onProgress` ("failed") and `status()`, and a later
|
||||||
* success clears the error. `open()` itself resolves even when the first
|
* success clears the error. `open()` itself resolves even when the first
|
||||||
* refresh fails (offline start from cache).
|
* refresh fails (offline start from cache).
|
||||||
* 5. `close()` stops the timer and is idempotent.
|
* 5. `close()` stops the timer and is idempotent, and its promise resolves
|
||||||
|
* only once an in-flight refresh has written the cache file.
|
||||||
* 6. `cacheDirectory` defaults to the env-paths cache dir plus the user id.
|
* 6. `cacheDirectory` defaults to the env-paths cache dir plus the user id.
|
||||||
* 7. `open()` branches on the cache: an empty cache awaits the first refresh
|
* 7. `open()` branches on the cache: an empty cache awaits the first refresh
|
||||||
* (it has nothing to serve yet); an existing cache serves its copy at once
|
* (it has nothing to serve yet); an existing cache serves its copy at once
|
||||||
@@ -37,6 +38,11 @@
|
|||||||
* short interval and `vi.waitFor`: a fake clock cannot settle the real
|
* short interval and `vi.waitFor`: a fake clock cannot settle the real
|
||||||
* fsync-and-rename cache write, and empty diffs never write, so the eventual
|
* fsync-and-rename cache write, and empty diffs never write, so the eventual
|
||||||
* state is stable to poll for.
|
* state is stable to poll for.
|
||||||
|
*
|
||||||
|
* A refresh changes RAM before it writes the cache file, so a polled state can
|
||||||
|
* be visible while that write is still running. Every test therefore awaits
|
||||||
|
* `close()`, which waits for the in-flight refresh, before `afterEach` removes
|
||||||
|
* the directory.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
@@ -193,7 +199,7 @@ describe("Library.open and background refresh", () => {
|
|||||||
expect(reloaded.getFile(1, 1001)?.id).toBe(1001);
|
expect(reloaded.getFile(1, 1001)?.id).toBe(1001);
|
||||||
expect(reloaded.collectionsSinceTime).toBe(100);
|
expect(reloaded.collectionsSinceTime).toBe(100);
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -224,7 +230,7 @@ describe("Library.open and background refresh", () => {
|
|||||||
expect(client.collectionsSinceTimes.length).toBe(collectionCalls);
|
expect(client.collectionsSinceTimes.length).toBe(collectionCalls);
|
||||||
expect(client.filesCalls.length).toBe(fileCalls);
|
expect(client.filesCalls.length).toBe(fileCalls);
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -271,7 +277,7 @@ describe("Library.open and background refresh", () => {
|
|||||||
{ timeout: 2000, interval: 5 },
|
{ timeout: 2000, interval: 5 },
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -303,7 +309,7 @@ describe("Library.open and background refresh", () => {
|
|||||||
// never re-fetched.
|
// never re-fetched.
|
||||||
expect(client.filesCalls).toEqual([]);
|
expect(client.filesCalls).toEqual([]);
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -357,7 +363,7 @@ describe("Library.open and background refresh", () => {
|
|||||||
{ timeout: 2000, interval: 5 },
|
{ timeout: 2000, interval: 5 },
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -402,7 +408,7 @@ describe("Library.open and background refresh", () => {
|
|||||||
await new Promise((r) => setTimeout(r, FAST_INTERVAL * 1000 * 4));
|
await new Promise((r) => setTimeout(r, FAST_INTERVAL * 1000 * 4));
|
||||||
expect(saveSpy).toHaveBeenCalledTimes(2);
|
expect(saveSpy).toHaveBeenCalledTimes(2);
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
saveSpy.mockRestore();
|
saveSpy.mockRestore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -462,7 +468,7 @@ describe("Library.open and background refresh", () => {
|
|||||||
{ timeout: 2000, interval: 5 },
|
{ timeout: 2000, interval: 5 },
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -488,7 +494,7 @@ describe("Library.open and background refresh", () => {
|
|||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -502,9 +508,14 @@ describe("Library.open and background refresh", () => {
|
|||||||
seed.putFile(file(1001, 1, 400));
|
seed.putFile(file(1001, 1, 400));
|
||||||
await seed.save();
|
await seed.save();
|
||||||
|
|
||||||
// The server never answers this run's first refresh.
|
// The server does not answer this run's first refresh until the test
|
||||||
|
// is done with it.
|
||||||
|
let answerFirstFetch: (page: CollectionsPage) => void = () => {};
|
||||||
const client = new MockClient();
|
const client = new MockClient();
|
||||||
client.collectionsSince = () => new Promise<CollectionsPage>(() => {});
|
client.collectionsSince = () =>
|
||||||
|
new Promise<CollectionsPage>((resolve) => {
|
||||||
|
answerFirstFetch = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
// open() must resolve from the cache without blocking on the network,
|
// open() must resolve from the cache without blocking on the network,
|
||||||
// and reads must serve the seeded copy.
|
// and reads must serve the seeded copy.
|
||||||
@@ -517,7 +528,9 @@ describe("Library.open and background refresh", () => {
|
|||||||
expect(lib.status().lastRefreshAt).toBeUndefined();
|
expect(lib.status().lastRefreshAt).toBeUndefined();
|
||||||
expect(lib.status().lastError).toBeUndefined();
|
expect(lib.status().lastError).toBeUndefined();
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
// close() waits for the outstanding refresh, so let it finish.
|
||||||
|
answerFirstFetch({ collections: [], deleted: [], cursor: 500 });
|
||||||
|
await lib.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -564,7 +577,7 @@ describe("Library.open and background refresh", () => {
|
|||||||
expect(lib.listFiles(1).map((f) => f.id)).toEqual([1001]);
|
expect(lib.listFiles(1).map((f) => f.id)).toEqual([1001]);
|
||||||
expect(lib.status().lastRefreshAt).toBeGreaterThan(0);
|
expect(lib.status().lastRefreshAt).toBeGreaterThan(0);
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -619,7 +632,7 @@ describe("Library.open and background refresh", () => {
|
|||||||
);
|
);
|
||||||
expect(reloaded.getFile(1, 1001)?.id).toBe(1001);
|
expect(reloaded.getFile(1, 1001)?.id).toBe(1001);
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
saveSpy.mockRestore();
|
saveSpy.mockRestore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -634,8 +647,8 @@ describe("Library.open and background refresh", () => {
|
|||||||
});
|
});
|
||||||
const callsAfterOpen = client.collectionsSinceTimes.length;
|
const callsAfterOpen = client.collectionsSinceTimes.length;
|
||||||
|
|
||||||
lib.close();
|
await lib.close();
|
||||||
lib.close(); // second close must not throw
|
await lib.close(); // second close must not throw
|
||||||
expect(lib.status().closed).toBe(true);
|
expect(lib.status().closed).toBe(true);
|
||||||
|
|
||||||
// No further refreshes fire once closed.
|
// No further refreshes fire once closed.
|
||||||
@@ -643,6 +656,65 @@ describe("Library.open and background refresh", () => {
|
|||||||
expect(client.collectionsSinceTimes.length).toBe(callsAfterOpen);
|
expect(client.collectionsSinceTimes.length).toBe(callsAfterOpen);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("close() resolves only after an in-flight refresh has written the cache", async () => {
|
||||||
|
const path = join(cacheDirectory, "metadata.json");
|
||||||
|
const seed = await MetadataStore.load(path);
|
||||||
|
seed.userID = USER_ID;
|
||||||
|
seed.collectionsSinceTime = 500;
|
||||||
|
seed.putCollection(collection(1, 400));
|
||||||
|
seed.putFile(file(1001, 1, 400));
|
||||||
|
await seed.save();
|
||||||
|
|
||||||
|
const client = new MockClient();
|
||||||
|
client.collectionsQueue.push({
|
||||||
|
collections: [collection(1, 600)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 600,
|
||||||
|
});
|
||||||
|
client.filesFor(1, {
|
||||||
|
files: [file(1002, 1, 600)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hold the refresh's cache write until the test releases it.
|
||||||
|
const realSave = MetadataStore.prototype.save;
|
||||||
|
let releaseSave: () => void = () => {};
|
||||||
|
const saveHeld = new Promise<void>((resolve) => {
|
||||||
|
releaseSave = resolve;
|
||||||
|
});
|
||||||
|
const saveSpy = vi
|
||||||
|
.spyOn(MetadataStore.prototype, "save")
|
||||||
|
.mockImplementation(async function (this: MetadataStore) {
|
||||||
|
await saveHeld;
|
||||||
|
return realSave.call(this);
|
||||||
|
});
|
||||||
|
|
||||||
|
const lib = await Library.open({ client, cacheDirectory });
|
||||||
|
try {
|
||||||
|
await vi.waitFor(() => expect(saveSpy).toHaveBeenCalled(), {
|
||||||
|
timeout: 2000,
|
||||||
|
interval: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
let closed = false;
|
||||||
|
const closing = lib.close().then(() => {
|
||||||
|
closed = true;
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
expect(closed).toBe(false);
|
||||||
|
|
||||||
|
releaseSave();
|
||||||
|
await closing;
|
||||||
|
const reloaded = await MetadataStore.load(path);
|
||||||
|
expect(reloaded.getFile(1, 1002)?.id).toBe(1002);
|
||||||
|
} finally {
|
||||||
|
releaseSave();
|
||||||
|
await lib.close();
|
||||||
|
saveSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults cacheDirectory to the env-paths cache dir plus user id", async () => {
|
it("defaults cacheDirectory to the env-paths cache dir plus user id", async () => {
|
||||||
const xdg = join(dir, "xdg-cache");
|
const xdg = join(dir, "xdg-cache");
|
||||||
const prev = process.env.XDG_CACHE_HOME;
|
const prev = process.env.XDG_CACHE_HOME;
|
||||||
@@ -659,7 +731,7 @@ describe("Library.open and background refresh", () => {
|
|||||||
expect(lib.cacheDirectory.startsWith(xdg)).toBe(true);
|
expect(lib.cacheDirectory.startsWith(xdg)).toBe(true);
|
||||||
expect(lib.cacheDirectory.endsWith(String(USER_ID))).toBe(true);
|
expect(lib.cacheDirectory.endsWith(String(USER_ID))).toBe(true);
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (prev === undefined) delete process.env.XDG_CACHE_HOME;
|
if (prev === undefined) delete process.env.XDG_CACHE_HOME;
|
||||||
|
|||||||
@@ -374,7 +374,7 @@ describe("Library ML-data fetch on refresh", () => {
|
|||||||
await new Promise((r) => setTimeout(r, FAST_INTERVAL * 1000 * 5));
|
await new Promise((r) => setTimeout(r, FAST_INTERVAL * 1000 * 5));
|
||||||
expect(client.mlFetchCalls.length).toBe(callsAfterFirst);
|
expect(client.mlFetchCalls.length).toBe(callsAfterFirst);
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -443,7 +443,59 @@ describe("Library ML-data fetch on refresh", () => {
|
|||||||
expect(client.mlFetchCalls.length).toBeGreaterThan(callsBefore);
|
expect(client.mlFetchCalls.length).toBeGreaterThan(callsBefore);
|
||||||
expect(client.mlFetchCalls.flat()).toContain(1001);
|
expect(client.mlFetchCalls.flat()).toContain(1001);
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("close() resolves only after a running ML data fetch has stored its payloads", async () => {
|
||||||
|
const client = new MLMockClient();
|
||||||
|
client.collectionsQueue.push({
|
||||||
|
collections: [collection(1, 100)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 100,
|
||||||
|
});
|
||||||
|
client.filesFor(1, {
|
||||||
|
files: [file(1001, 1, 90)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 90,
|
||||||
|
});
|
||||||
|
client.mlByFile.set(1001, payload([0.5, 0.25, 0.75]));
|
||||||
|
|
||||||
|
// Hold the ML data fetch open until the test releases it.
|
||||||
|
let release!: () => void;
|
||||||
|
const held = new Promise<void>((r) => (release = r));
|
||||||
|
let fetchStarted!: () => void;
|
||||||
|
const started = new Promise<void>((r) => (fetchStarted = r));
|
||||||
|
const realFetch = client.fetchMLData.bind(client);
|
||||||
|
client.fetchMLData = async (args) => {
|
||||||
|
fetchStarted();
|
||||||
|
await held;
|
||||||
|
return realFetch(args);
|
||||||
|
};
|
||||||
|
|
||||||
|
const lib = await Library.open({
|
||||||
|
client,
|
||||||
|
cacheDirectory,
|
||||||
|
refreshIntervalSeconds: 3600,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await started;
|
||||||
|
|
||||||
|
let closed = false;
|
||||||
|
const closing = lib.close().then(() => {
|
||||||
|
closed = true;
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(closed).toBe(false);
|
||||||
|
|
||||||
|
release();
|
||||||
|
await closing;
|
||||||
|
expect(
|
||||||
|
existsSync(join(cacheDirectory, "mldata", "1001.json")),
|
||||||
|
).toBe(true);
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
await lib.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -402,16 +402,64 @@ describe("Precache through Library.open", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it("starts both precaches from open() and reports them in status()", async () => {
|
it("starts both precaches from open() and reports them in status()", async () => {
|
||||||
const thumbFetched = new Set<number>();
|
|
||||||
const origFetched = new Set<number>();
|
|
||||||
const source: ContentSource = {
|
const source: ContentSource = {
|
||||||
original: async ({ file: f, destination }) => {
|
original: async ({ destination }) => {
|
||||||
origFetched.add(f.id);
|
|
||||||
await writeFile(destination, Buffer.alloc(10, 1));
|
await writeFile(destination, Buffer.alloc(10, 1));
|
||||||
return { bytesWritten: 10 };
|
return { bytesWritten: 10 };
|
||||||
},
|
},
|
||||||
thumbnail: async ({ file: f, destination }) => {
|
thumbnail: async ({ destination }) => {
|
||||||
thumbFetched.add(f.id);
|
await writeFile(destination, Buffer.alloc(10, 1));
|
||||||
|
return { bytesWritten: 10 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Each fill reports "done" once the cache has recorded its files. The
|
||||||
|
// source returning is not enough: the cache records a file only after
|
||||||
|
// it has checked it on disk.
|
||||||
|
const finished = new Set<string>();
|
||||||
|
let bothFinished!: () => void;
|
||||||
|
const precached = new Promise<void>((r) => (bothFinished = r));
|
||||||
|
const lib = await Library.open({
|
||||||
|
client: new MockClient(),
|
||||||
|
cacheDirectory: join(root, "cache"),
|
||||||
|
contentSource: source,
|
||||||
|
refreshIntervalSeconds: 3600,
|
||||||
|
onProgress: (e) => {
|
||||||
|
if (
|
||||||
|
e.status === "done" &&
|
||||||
|
(e.operation === "precacheThumbnails" ||
|
||||||
|
e.operation === "precacheOriginals")
|
||||||
|
) {
|
||||||
|
finished.add(e.operation);
|
||||||
|
if (finished.size === 2) bothFinished();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Every file's thumbnail is precached; the favorite (file 3) and the
|
||||||
|
// week's files (1, 2) all have their originals precached.
|
||||||
|
await precached;
|
||||||
|
const status = lib.status();
|
||||||
|
expect(status.thumbnailsTotal).toBe(3);
|
||||||
|
expect(status.thumbnailsCached).toBe(3);
|
||||||
|
expect(status.originalsPinned).toBe(3);
|
||||||
|
expect(status.originalsCached).toBe(3);
|
||||||
|
await lib.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("close() resolves only after a running precache fetch has written its file", async () => {
|
||||||
|
// Every thumbnail fetch waits until the test releases it.
|
||||||
|
let release!: () => void;
|
||||||
|
const held = new Promise<void>((r) => (release = r));
|
||||||
|
let fetchStarted!: (destination: string) => void;
|
||||||
|
const started = new Promise<string>((r) => (fetchStarted = r));
|
||||||
|
const source: ContentSource = {
|
||||||
|
original: async ({ destination }) => {
|
||||||
|
await writeFile(destination, Buffer.alloc(10, 1));
|
||||||
|
return { bytesWritten: 10 };
|
||||||
|
},
|
||||||
|
thumbnail: async ({ destination }) => {
|
||||||
|
fetchStarted(destination);
|
||||||
|
await held;
|
||||||
await writeFile(destination, Buffer.alloc(10, 1));
|
await writeFile(destination, Buffer.alloc(10, 1));
|
||||||
return { bytesWritten: 10 };
|
return { bytesWritten: 10 };
|
||||||
},
|
},
|
||||||
@@ -421,16 +469,24 @@ describe("Precache through Library.open", () => {
|
|||||||
cacheDirectory: join(root, "cache"),
|
cacheDirectory: join(root, "cache"),
|
||||||
contentSource: source,
|
contentSource: source,
|
||||||
refreshIntervalSeconds: 3600,
|
refreshIntervalSeconds: 3600,
|
||||||
|
precacheOriginals: false,
|
||||||
});
|
});
|
||||||
|
try {
|
||||||
|
const destination = await started;
|
||||||
|
|
||||||
// Every file's thumbnail is precached; the favorite (file 3) and the
|
let closed = false;
|
||||||
// week's files (1, 2) all have their originals precached.
|
const closing = lib.close().then(() => {
|
||||||
await until(() => thumbFetched.size === 3 && origFetched.size === 3);
|
closed = true;
|
||||||
const status = lib.status();
|
});
|
||||||
expect(status.thumbnailsTotal).toBe(3);
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
expect(status.thumbnailsCached).toBe(3);
|
expect(closed).toBe(false);
|
||||||
expect(status.originalsPinned).toBe(3);
|
|
||||||
expect(status.originalsCached).toBe(3);
|
release();
|
||||||
lib.close();
|
await closing;
|
||||||
|
expect(existsSync(destination)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
await lib.close();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -531,7 +531,7 @@ describe("Library exposes the read surface over its live store", () => {
|
|||||||
expect(client.collectionsCalls).toBe(collectionsBefore);
|
expect(client.collectionsCalls).toBe(collectionsBefore);
|
||||||
expect(client.filesCalls).toBe(filesBefore);
|
expect(client.filesCalls).toBe(filesBefore);
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ describe("Library.snapshot and Library.subscribe", () => {
|
|||||||
for (const p of snap.photos) expect("key" in p).toBe(false);
|
for (const p of snap.photos) expect("key" in p).toBe(false);
|
||||||
for (const a of snap.albums) expect("key" in a).toBe(false);
|
for (const a of snap.albums) expect("key" in a).toBe(false);
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -219,7 +219,7 @@ describe("Library.snapshot and Library.subscribe", () => {
|
|||||||
expect(change.refreshedAt).toBeGreaterThan(0);
|
expect(change.refreshedAt).toBeGreaterThan(0);
|
||||||
} finally {
|
} finally {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -251,7 +251,7 @@ describe("Library.snapshot and Library.subscribe", () => {
|
|||||||
expect(changes).toEqual([]);
|
expect(changes).toEqual([]);
|
||||||
} finally {
|
} finally {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -297,7 +297,7 @@ describe("Library.snapshot and Library.subscribe", () => {
|
|||||||
);
|
);
|
||||||
expect(changes).toEqual([]);
|
expect(changes).toEqual([]);
|
||||||
} finally {
|
} finally {
|
||||||
lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user