Plain-record library snapshot and change subscription #62
@@ -42,6 +42,12 @@ export {
|
|||||||
type RefreshEvent,
|
type RefreshEvent,
|
||||||
type RefreshProgressCallback,
|
type RefreshProgressCallback,
|
||||||
} from "./library/index.js";
|
} from "./library/index.js";
|
||||||
|
export type {
|
||||||
|
AlbumRecord,
|
||||||
|
PhotoRecord,
|
||||||
|
LibrarySnapshot,
|
||||||
|
LibraryChange,
|
||||||
|
} from "./library/records.js";
|
||||||
export { decryptCollection, decryptFile } from "./model/index.js";
|
export { decryptCollection, decryptFile } from "./model/index.js";
|
||||||
export { downloadFile, downloadThumbnail } from "./download/index.js";
|
export { downloadFile, downloadThumbnail } from "./download/index.js";
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ import { join } from "node:path";
|
|||||||
import envPaths from "env-paths";
|
import envPaths from "env-paths";
|
||||||
|
|
||||||
import { MetadataStore } from "./store.js";
|
import { MetadataStore } from "./store.js";
|
||||||
|
import {
|
||||||
|
deriveRecords,
|
||||||
|
snapshotFrom,
|
||||||
|
diffRecords,
|
||||||
|
type DerivedRecords,
|
||||||
|
type LibrarySnapshot,
|
||||||
|
type LibraryChange,
|
||||||
|
} from "./records.js";
|
||||||
import type { CollectionsPage, FilesPage } from "../client.js";
|
import type { CollectionsPage, FilesPage } from "../client.js";
|
||||||
import type { Collection, EnteFile } from "../model/types.js";
|
import type { Collection, EnteFile } from "../model/types.js";
|
||||||
|
|
||||||
@@ -91,6 +99,12 @@ export class Library {
|
|||||||
private closed = false;
|
private closed = false;
|
||||||
private lastRefreshAt?: number;
|
private lastRefreshAt?: number;
|
||||||
private lastError?: string;
|
private lastError?: string;
|
||||||
|
// The plain-record projection as of the last refresh, and the GUI change
|
||||||
|
// subscribers. A refresh that alters the projection notifies each with the
|
||||||
|
// delta; `lastRecords` is kept current every refresh so a subscriber that
|
||||||
|
// joins later diffs against the state its own `snapshot()` already returned.
|
||||||
|
private readonly subscribers = new Set<(change: LibraryChange) => void>();
|
||||||
|
private lastRecords: DerivedRecords;
|
||||||
// RAM holds changes disk has not yet accepted (an earlier save failed).
|
// RAM holds changes disk has not yet accepted (an earlier save failed).
|
||||||
// Cleared only when a save actually succeeds; keeps the store trying to
|
// Cleared only when a save actually succeeds; keeps the store trying to
|
||||||
// persist and the failure visible in `status()` until then.
|
// persist and the failure visible in `status()` until then.
|
||||||
@@ -112,6 +126,7 @@ export class Library {
|
|||||||
this.downloadDirectory = args.downloadDirectory;
|
this.downloadDirectory = args.downloadDirectory;
|
||||||
this.intervalMs = args.intervalMs;
|
this.intervalMs = args.intervalMs;
|
||||||
this.onProgress = args.onProgress;
|
this.onProgress = args.onProgress;
|
||||||
|
this.lastRecords = this.deriveNow();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the cache and start the refresh loop. With an empty cache the first
|
// Load the cache and start the refresh loop. With an empty cache the first
|
||||||
@@ -172,6 +187,28 @@ export class Library {
|
|||||||
return this.store.getFile(collectionID, fileID);
|
return this.store.getFile(collectionID, fileID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A synchronous, RAM-only projection of the whole library into plain
|
||||||
|
// records (no keys), the surface the GUI reads across IPC. Photos are
|
||||||
|
// deduplicated to one record per file and ordered newest first.
|
||||||
|
snapshot(): LibrarySnapshot {
|
||||||
|
return snapshotFrom(this.deriveNow(), Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver a `LibraryChange` whenever a refresh alters the projection. A
|
||||||
|
// refresh that changes nothing delivers nothing. The returned handle's
|
||||||
|
// `unsubscribe` stops delivery.
|
||||||
|
subscribe(args: { onChange: (change: LibraryChange) => void }): {
|
||||||
|
unsubscribe: () => void;
|
||||||
|
} {
|
||||||
|
const { onChange } = args;
|
||||||
|
this.subscribers.add(onChange);
|
||||||
|
return {
|
||||||
|
unsubscribe: () => {
|
||||||
|
this.subscribers.delete(onChange);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
status(): LibraryStatus {
|
status(): LibraryStatus {
|
||||||
let files = 0;
|
let files = 0;
|
||||||
const collections = this.store.listCollections();
|
const collections = this.store.listCollections();
|
||||||
@@ -292,6 +329,20 @@ export class Library {
|
|||||||
|
|
||||||
if (changed) this.unsaved = true;
|
if (changed) this.unsaved = true;
|
||||||
|
|
||||||
|
// Reproject and notify subscribers of the delta. This tracks RAM (what
|
||||||
|
// reads see), so it fires whether or not the save below succeeds; a
|
||||||
|
// save failure surfaces separately through `status().lastError`.
|
||||||
|
// `lastRecords` advances every changed refresh so the next diff is
|
||||||
|
// against current state.
|
||||||
|
if (changed) {
|
||||||
|
const next = this.deriveNow();
|
||||||
|
if (this.subscribers.size > 0) {
|
||||||
|
const change = diffRecords(this.lastRecords, next, Date.now());
|
||||||
|
if (change) this.notify(change);
|
||||||
|
}
|
||||||
|
this.lastRecords = next;
|
||||||
|
}
|
||||||
|
|
||||||
// Persist whenever RAM holds changes disk has not accepted — including
|
// Persist whenever RAM holds changes disk has not accepted — including
|
||||||
// changes an earlier cycle staged whose save failed. `unsaved` clears
|
// changes an earlier cycle staged whose save failed. `unsaved` clears
|
||||||
// only once a save lands, so a save failure both stays visible through
|
// only once a save lands, so a save failure both stays visible through
|
||||||
@@ -304,6 +355,25 @@ export class Library {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gather every file membership and project the store into by-id records.
|
||||||
|
private deriveNow(): DerivedRecords {
|
||||||
|
const collections = this.store.listCollections();
|
||||||
|
const files: EnteFile[] = [];
|
||||||
|
for (const c of collections) files.push(...this.store.listFiles(c.id));
|
||||||
|
return deriveRecords(collections, files);
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(change: LibraryChange): void {
|
||||||
|
for (const onChange of this.subscribers) {
|
||||||
|
// A misbehaving subscriber must not break the loop or its peers.
|
||||||
|
try {
|
||||||
|
onChange(change);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private emit(event: RefreshEvent): void {
|
private emit(event: RefreshEvent): void {
|
||||||
if (!this.onProgress) return;
|
if (!this.onProgress) return;
|
||||||
// A misbehaving callback must not break the refresh loop.
|
// A misbehaving callback must not break the refresh loop.
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
// Plain records projected from the decrypted store, and the diff between two
|
||||||
|
// projections. These are the library's GUI-facing surface: they hold no key
|
||||||
|
// material and no binary, so they survive `structuredClone`/JSON across the
|
||||||
|
// Electron IPC boundary where methods and file keys cannot go (design #36,
|
||||||
|
// owner ruling 5). The decrypted `Collection`/`EnteFile` objects stay in RAM in
|
||||||
|
// the main process; the window only ever sees these records.
|
||||||
|
//
|
||||||
|
// Ente holds edited/basic times in microseconds; records expose `takenAt` in
|
||||||
|
// milliseconds. The magic-metadata field names below are the ones the Ente
|
||||||
|
// clients write, confirmed against the repo's own fixtures: `w`/`h` in
|
||||||
|
// test/cli/metadata-backup.test.ts, `visibility` in test/library/store.test.ts.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Collection,
|
||||||
|
CollectionType,
|
||||||
|
EnteFile,
|
||||||
|
FileType,
|
||||||
|
} from "../model/types.js";
|
||||||
|
|
||||||
|
// Ente private-magic-metadata visibility values.
|
||||||
|
const VISIBILITY_ARCHIVED = 1;
|
||||||
|
const VISIBILITY_HIDDEN = 2;
|
||||||
|
|
||||||
|
// A single photo, deduplicated across the collections it belongs to. No key,
|
||||||
|
// no binary: safe to send to a window.
|
||||||
|
export interface PhotoRecord {
|
||||||
|
fileID: number;
|
||||||
|
// Every collection this file is a member of, ascending.
|
||||||
|
albumIDs: number[];
|
||||||
|
// `pubMagicMetadata.editedName` when the user renamed the file, else the
|
||||||
|
// basic-metadata title.
|
||||||
|
title: string;
|
||||||
|
// Milliseconds. `pubMagicMetadata.editedTime` when the user edited the
|
||||||
|
// date, else basic-metadata `creationTime`.
|
||||||
|
takenAt: number;
|
||||||
|
fileType: FileType;
|
||||||
|
caption?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
isArchived: boolean;
|
||||||
|
isHidden: boolean;
|
||||||
|
// Local cache paths, set once a later phase caches the bytes; unset here.
|
||||||
|
thumbnailPath?: string;
|
||||||
|
originalPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlbumRecord {
|
||||||
|
collectionID: number;
|
||||||
|
name: string;
|
||||||
|
// `favorites` identifies the account's favorites album.
|
||||||
|
type: CollectionType;
|
||||||
|
isShared: boolean;
|
||||||
|
updationTime: number;
|
||||||
|
// The album's files, newest first.
|
||||||
|
fileIDs: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LibrarySnapshot {
|
||||||
|
albums: AlbumRecord[];
|
||||||
|
photos: PhotoRecord[];
|
||||||
|
// Wall-clock milliseconds when the snapshot was taken.
|
||||||
|
takenAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LibraryChange {
|
||||||
|
// Full records for albums/photos added or changed by the refresh.
|
||||||
|
albumsChanged: AlbumRecord[];
|
||||||
|
photosChanged: PhotoRecord[];
|
||||||
|
fileIDsRemoved: number[];
|
||||||
|
albumIDsRemoved: number[];
|
||||||
|
// Wall-clock milliseconds of the refresh that produced this change.
|
||||||
|
refreshedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The by-id projection of the store at one moment; the source for both
|
||||||
|
// `snapshotFrom` (sorted arrays for the GUI) and `diffRecords` (change sets).
|
||||||
|
export interface DerivedRecords {
|
||||||
|
albums: Map<number, AlbumRecord>;
|
||||||
|
photos: Map<number, PhotoRecord>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asString = (v: unknown): string | undefined =>
|
||||||
|
typeof v === "string" && v.length > 0 ? v : undefined;
|
||||||
|
|
||||||
|
const asNumber = (v: unknown): number | undefined =>
|
||||||
|
typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
||||||
|
|
||||||
|
const microsToMillis = (micros: number): number => Math.floor(micros / 1000);
|
||||||
|
|
||||||
|
// Newest first, with fileID as a stable tiebreak so equal-timed files order
|
||||||
|
// deterministically.
|
||||||
|
const byNewestPhoto = (a: PhotoRecord, b: PhotoRecord): number =>
|
||||||
|
b.takenAt - a.takenAt || b.fileID - a.fileID;
|
||||||
|
|
||||||
|
// Build one PhotoRecord from every membership of a file. The memberships share
|
||||||
|
// the same underlying file, so metadata is read from a single representative
|
||||||
|
// (the most recently synced, lowest collection id to break ties); `albumIDs`
|
||||||
|
// gathers them all.
|
||||||
|
const toPhotoRecord = (
|
||||||
|
fileID: number,
|
||||||
|
memberships: EnteFile[],
|
||||||
|
): PhotoRecord => {
|
||||||
|
const albumIDs = memberships
|
||||||
|
.map((m) => m.collectionID)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
const rep = memberships.reduce((best, m) =>
|
||||||
|
m.updationTime > best.updationTime ||
|
||||||
|
(m.updationTime === best.updationTime &&
|
||||||
|
m.collectionID < best.collectionID)
|
||||||
|
? m
|
||||||
|
: best,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pub = rep.pubMagicMetadata ?? {};
|
||||||
|
const priv = rep.magicMetadata ?? {};
|
||||||
|
|
||||||
|
const takenAtMicros = asNumber(pub.editedTime) ?? rep.metadata.creationTime;
|
||||||
|
const visibility = asNumber(priv.visibility);
|
||||||
|
|
||||||
|
const record: PhotoRecord = {
|
||||||
|
fileID,
|
||||||
|
albumIDs,
|
||||||
|
title: asString(pub.editedName) ?? rep.metadata.title,
|
||||||
|
takenAt: microsToMillis(takenAtMicros),
|
||||||
|
fileType: rep.metadata.fileType,
|
||||||
|
isArchived: visibility === VISIBILITY_ARCHIVED,
|
||||||
|
isHidden: visibility === VISIBILITY_HIDDEN,
|
||||||
|
};
|
||||||
|
|
||||||
|
const caption = asString(pub.caption);
|
||||||
|
if (caption !== undefined) record.caption = caption;
|
||||||
|
const width = asNumber(pub.w);
|
||||||
|
if (width !== undefined) record.width = width;
|
||||||
|
const height = asNumber(pub.h);
|
||||||
|
if (height !== undefined) record.height = height;
|
||||||
|
if (rep.metadata.latitude !== undefined)
|
||||||
|
record.latitude = rep.metadata.latitude;
|
||||||
|
if (rep.metadata.longitude !== undefined)
|
||||||
|
record.longitude = rep.metadata.longitude;
|
||||||
|
|
||||||
|
return record;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toAlbumRecord = (
|
||||||
|
collection: Collection,
|
||||||
|
files: EnteFile[],
|
||||||
|
takenAtByFile: Map<number, number>,
|
||||||
|
): AlbumRecord => {
|
||||||
|
const fileIDs = files
|
||||||
|
.filter((f) => f.collectionID === collection.id)
|
||||||
|
.map((f) => f.id)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(takenAtByFile.get(b) ?? 0) - (takenAtByFile.get(a) ?? 0) ||
|
||||||
|
b - a,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
collectionID: collection.id,
|
||||||
|
name: collection.name,
|
||||||
|
type: collection.type,
|
||||||
|
isShared: collection.isShared,
|
||||||
|
updationTime: collection.updationTime,
|
||||||
|
fileIDs,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Project the decrypted collections and file memberships into by-id records.
|
||||||
|
// `files` is every membership (a file appears once per collection it is in).
|
||||||
|
export const deriveRecords = (
|
||||||
|
collections: Collection[],
|
||||||
|
files: EnteFile[],
|
||||||
|
): DerivedRecords => {
|
||||||
|
const byFileID = new Map<number, EnteFile[]>();
|
||||||
|
for (const f of files) {
|
||||||
|
const arr = byFileID.get(f.id);
|
||||||
|
if (arr) arr.push(f);
|
||||||
|
else byFileID.set(f.id, [f]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const photos = new Map<number, PhotoRecord>();
|
||||||
|
const takenAtByFile = new Map<number, number>();
|
||||||
|
for (const [fileID, memberships] of byFileID) {
|
||||||
|
const record = toPhotoRecord(fileID, memberships);
|
||||||
|
photos.set(fileID, record);
|
||||||
|
takenAtByFile.set(fileID, record.takenAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const albums = new Map<number, AlbumRecord>();
|
||||||
|
for (const c of collections) {
|
||||||
|
albums.set(c.id, toAlbumRecord(c, files, takenAtByFile));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { albums, photos };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sorted, GUI-ready arrays: albums newest updated first, photos newest first.
|
||||||
|
export const snapshotFrom = (
|
||||||
|
records: DerivedRecords,
|
||||||
|
takenAt: number,
|
||||||
|
): LibrarySnapshot => ({
|
||||||
|
albums: [...records.albums.values()].sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.updationTime - a.updationTime || b.collectionID - a.collectionID,
|
||||||
|
),
|
||||||
|
photos: [...records.photos.values()].sort(byNewestPhoto),
|
||||||
|
takenAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Records compare by value; they are plain and built with a fixed key order, so
|
||||||
|
// a serialized form is a sound equality key.
|
||||||
|
const same = (a: unknown, b: unknown): boolean =>
|
||||||
|
JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
|
||||||
|
const diffMap = <T>(
|
||||||
|
prev: Map<number, T>,
|
||||||
|
next: Map<number, T>,
|
||||||
|
): { changed: T[]; removed: number[] } => {
|
||||||
|
const changed: T[] = [];
|
||||||
|
for (const [id, record] of next) {
|
||||||
|
const before = prev.get(id);
|
||||||
|
if (before === undefined || !same(before, record)) changed.push(record);
|
||||||
|
}
|
||||||
|
const removed: number[] = [];
|
||||||
|
for (const id of prev.keys()) if (!next.has(id)) removed.push(id);
|
||||||
|
removed.sort((a, b) => a - b);
|
||||||
|
return { changed, removed };
|
||||||
|
};
|
||||||
|
|
||||||
|
// The change between two projections, or undefined when nothing changed.
|
||||||
|
export const diffRecords = (
|
||||||
|
prev: DerivedRecords,
|
||||||
|
next: DerivedRecords,
|
||||||
|
refreshedAt: number,
|
||||||
|
): LibraryChange | undefined => {
|
||||||
|
const albums = diffMap(prev.albums, next.albums);
|
||||||
|
const photos = diffMap(prev.photos, next.photos);
|
||||||
|
if (
|
||||||
|
albums.changed.length === 0 &&
|
||||||
|
albums.removed.length === 0 &&
|
||||||
|
photos.changed.length === 0 &&
|
||||||
|
photos.removed.length === 0
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
albumsChanged: albums.changed,
|
||||||
|
photosChanged: photos.changed,
|
||||||
|
fileIDsRemoved: photos.removed,
|
||||||
|
albumIDsRemoved: albums.removed,
|
||||||
|
refreshedAt,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the plain-record mapping in `src/library/records.ts` (issue #43).
|
||||||
|
*
|
||||||
|
* The library keeps decrypted `Collection`/`EnteFile` objects in RAM, but those
|
||||||
|
* carry binary keys and cannot cross the Electron IPC boundary. `deriveRecords`
|
||||||
|
* projects them into plain `AlbumRecord`/`PhotoRecord` values — no keys, no
|
||||||
|
* `Uint8Array`, JSON-safe — that the GUI process consumes. This file pins:
|
||||||
|
*
|
||||||
|
* 1. Field mapping from `metadata` and the two magic-metadata layers, using the
|
||||||
|
* real Ente field names confirmed against the fixtures in
|
||||||
|
* `test/cli/metadata-backup.test.ts` (`w`/`h`) and `test/library/store.test.ts`
|
||||||
|
* (`visibility`): title/takenAt precedence, caption, width/height, geo,
|
||||||
|
* visibility → isArchived/isHidden, fileType.
|
||||||
|
* 2. `takenAt` is milliseconds; Ente stores creationTime/editedTime in
|
||||||
|
* microseconds, so the record divides by 1000.
|
||||||
|
* 3. Deduplication: one `PhotoRecord` per fileID even when the file belongs to
|
||||||
|
* several collections, with every membership's collection id in `albumIDs`.
|
||||||
|
* 4. Ordering: photos and album `fileIDs` are newest first.
|
||||||
|
* 5. No key material survives the projection.
|
||||||
|
* 6. `diffRecords` reports exactly what changed between two derivations, and
|
||||||
|
* returns undefined when nothing changed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
deriveRecords,
|
||||||
|
snapshotFrom,
|
||||||
|
diffRecords,
|
||||||
|
} from "../../src/library/records.js";
|
||||||
|
import type { Collection, EnteFile } from "../../src/model/types.js";
|
||||||
|
|
||||||
|
const OWNER = 42;
|
||||||
|
|
||||||
|
// Microsecond epoch values, as Ente stores times. 1e15 ≈ 2001 in microseconds.
|
||||||
|
const T = (micros: number): number => micros;
|
||||||
|
|
||||||
|
const collection = (
|
||||||
|
id: number,
|
||||||
|
opts: Partial<Collection> = {},
|
||||||
|
): Collection => ({
|
||||||
|
id,
|
||||||
|
ownerID: OWNER,
|
||||||
|
key: new Uint8Array([id & 0xff, 1, 2, 3]),
|
||||||
|
name: `album-${id}`,
|
||||||
|
type: "album",
|
||||||
|
updationTime: T(1_700_000_000_000_000),
|
||||||
|
isShared: false,
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
|
||||||
|
const file = (
|
||||||
|
id: number,
|
||||||
|
collectionID: number,
|
||||||
|
opts: Partial<EnteFile> & {
|
||||||
|
creationTime?: number;
|
||||||
|
title?: string;
|
||||||
|
} = {},
|
||||||
|
): EnteFile => {
|
||||||
|
const { creationTime, title, ...rest } = opts;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
collectionID,
|
||||||
|
ownerID: OWNER,
|
||||||
|
key: new Uint8Array([id & 0xff, 9, 8, 7]),
|
||||||
|
metadata: {
|
||||||
|
title: title ?? `file-${id}.jpg`,
|
||||||
|
fileType: "image",
|
||||||
|
creationTime: creationTime ?? T(1_700_000_000_000_000),
|
||||||
|
modificationTime: T(1_700_000_000_000_000),
|
||||||
|
},
|
||||||
|
file: { decryptionHeader: "aGVhZGVy" },
|
||||||
|
thumbnail: { decryptionHeader: "dGh1bWI=" },
|
||||||
|
updationTime: T(1_700_000_000_000_000),
|
||||||
|
...rest,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("deriveRecords: photo mapping", () => {
|
||||||
|
it("projects a file into a plain PhotoRecord with no key material", () => {
|
||||||
|
const f = file(1001, 1, {
|
||||||
|
metadata: {
|
||||||
|
title: "IMG_1.jpg",
|
||||||
|
fileType: "image",
|
||||||
|
creationTime: T(1_699_000_000_000_000),
|
||||||
|
modificationTime: T(1_699_000_000_000_000),
|
||||||
|
latitude: 52.52,
|
||||||
|
longitude: 13.405,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { photos } = deriveRecords([collection(1)], [f]);
|
||||||
|
const rec = photos.get(1001)!;
|
||||||
|
|
||||||
|
expect(rec.fileID).toBe(1001);
|
||||||
|
expect(rec.albumIDs).toEqual([1]);
|
||||||
|
expect(rec.title).toBe("IMG_1.jpg");
|
||||||
|
expect(rec.fileType).toBe("image");
|
||||||
|
expect(rec.latitude).toBeCloseTo(52.52);
|
||||||
|
expect(rec.longitude).toBeCloseTo(13.405);
|
||||||
|
expect(rec.isArchived).toBe(false);
|
||||||
|
expect(rec.isHidden).toBe(false);
|
||||||
|
|
||||||
|
// Safe to send over IPC: no key, no Uint8Array, JSON round-trips whole.
|
||||||
|
expect("key" in rec).toBe(false);
|
||||||
|
expect(JSON.parse(JSON.stringify(rec))).toEqual(rec);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takenAt is creationTime converted from microseconds to milliseconds", () => {
|
||||||
|
const f = file(1001, 1, { creationTime: T(1_699_000_000_000_000) });
|
||||||
|
const { photos } = deriveRecords([collection(1)], [f]);
|
||||||
|
expect(photos.get(1001)!.takenAt).toBe(1_699_000_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers pubMagicMetadata.editedName and editedTime over metadata", () => {
|
||||||
|
const f = file(1001, 1, {
|
||||||
|
title: "original.jpg",
|
||||||
|
creationTime: T(1_699_000_000_000_000),
|
||||||
|
pubMagicMetadata: {
|
||||||
|
editedName: "Sunset over the bay",
|
||||||
|
editedTime: T(1_650_000_000_000_000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { photos } = deriveRecords([collection(1)], [f]);
|
||||||
|
const rec = photos.get(1001)!;
|
||||||
|
expect(rec.title).toBe("Sunset over the bay");
|
||||||
|
expect(rec.takenAt).toBe(1_650_000_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to metadata when the edited fields are empty or absent", () => {
|
||||||
|
const f = file(1001, 1, {
|
||||||
|
title: "original.jpg",
|
||||||
|
creationTime: T(1_699_000_000_000_000),
|
||||||
|
pubMagicMetadata: { editedName: "" },
|
||||||
|
});
|
||||||
|
const { photos } = deriveRecords([collection(1)], [f]);
|
||||||
|
const rec = photos.get(1001)!;
|
||||||
|
expect(rec.title).toBe("original.jpg");
|
||||||
|
expect(rec.takenAt).toBe(1_699_000_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps caption and width/height from the public magic metadata", () => {
|
||||||
|
const f = file(1001, 1, {
|
||||||
|
pubMagicMetadata: {
|
||||||
|
caption: "at the beach",
|
||||||
|
w: 3000,
|
||||||
|
h: 2000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const rec = deriveRecords([collection(1)], [f]).photos.get(1001)!;
|
||||||
|
expect(rec.caption).toBe("at the beach");
|
||||||
|
expect(rec.width).toBe(3000);
|
||||||
|
expect(rec.height).toBe(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits optional fields that are absent from the metadata", () => {
|
||||||
|
const rec = deriveRecords([collection(1)], [file(1001, 1)]).photos.get(
|
||||||
|
1001,
|
||||||
|
)!;
|
||||||
|
expect("caption" in rec).toBe(false);
|
||||||
|
expect("width" in rec).toBe(false);
|
||||||
|
expect("height" in rec).toBe(false);
|
||||||
|
expect("latitude" in rec).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads archived and hidden from private magicMetadata.visibility", () => {
|
||||||
|
const archived = file(1, 1, { magicMetadata: { visibility: 1 } });
|
||||||
|
const hidden = file(2, 1, { magicMetadata: { visibility: 2 } });
|
||||||
|
const visible = file(3, 1, { magicMetadata: { visibility: 0 } });
|
||||||
|
const { photos } = deriveRecords(
|
||||||
|
[collection(1)],
|
||||||
|
[archived, hidden, visible],
|
||||||
|
);
|
||||||
|
expect(photos.get(1)).toMatchObject({
|
||||||
|
isArchived: true,
|
||||||
|
isHidden: false,
|
||||||
|
});
|
||||||
|
expect(photos.get(2)).toMatchObject({
|
||||||
|
isArchived: false,
|
||||||
|
isHidden: true,
|
||||||
|
});
|
||||||
|
expect(photos.get(3)).toMatchObject({
|
||||||
|
isArchived: false,
|
||||||
|
isHidden: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deriveRecords: dedup and ordering", () => {
|
||||||
|
it("emits one PhotoRecord per fileID across memberships, all albums listed", () => {
|
||||||
|
// File 1001 belongs to collections 1 and 2; 2002 only to 2.
|
||||||
|
const files = [
|
||||||
|
file(1001, 1, { creationTime: T(1_700_000_000_000_000) }),
|
||||||
|
file(1001, 2, { creationTime: T(1_700_000_000_000_000) }),
|
||||||
|
file(2002, 2, { creationTime: T(1_710_000_000_000_000) }),
|
||||||
|
];
|
||||||
|
const { photos } = deriveRecords([collection(1), collection(2)], files);
|
||||||
|
expect([...photos.keys()].sort((a, b) => a - b)).toEqual([1001, 2002]);
|
||||||
|
expect(photos.get(1001)!.albumIDs).toEqual([1, 2]);
|
||||||
|
expect(photos.get(2002)!.albumIDs).toEqual([2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders snapshot photos newest first by takenAt", () => {
|
||||||
|
const files = [
|
||||||
|
file(1, 1, { creationTime: T(1_600_000_000_000_000) }),
|
||||||
|
file(2, 1, { creationTime: T(1_800_000_000_000_000) }),
|
||||||
|
file(3, 1, { creationTime: T(1_700_000_000_000_000) }),
|
||||||
|
];
|
||||||
|
const snap = snapshotFrom(deriveRecords([collection(1)], files), 123);
|
||||||
|
expect(snap.photos.map((p) => p.fileID)).toEqual([2, 3, 1]);
|
||||||
|
expect(snap.takenAt).toBe(123);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders album fileIDs newest first", () => {
|
||||||
|
const files = [
|
||||||
|
file(1, 7, { creationTime: T(1_600_000_000_000_000) }),
|
||||||
|
file(2, 7, { creationTime: T(1_800_000_000_000_000) }),
|
||||||
|
file(3, 7, { creationTime: T(1_700_000_000_000_000) }),
|
||||||
|
];
|
||||||
|
const { albums } = deriveRecords([collection(7)], files);
|
||||||
|
expect(albums.get(7)!.fileIDs).toEqual([2, 3, 1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deriveRecords: album mapping", () => {
|
||||||
|
it("carries collection identity, sharing, and the favorites type", () => {
|
||||||
|
const fav = collection(9, {
|
||||||
|
name: "Favorites",
|
||||||
|
type: "favorites",
|
||||||
|
isShared: true,
|
||||||
|
updationTime: T(1_705_000_000_000_000),
|
||||||
|
});
|
||||||
|
const rec = deriveRecords([fav], [file(1, 9)]).albums.get(9)!;
|
||||||
|
expect(rec).toMatchObject({
|
||||||
|
collectionID: 9,
|
||||||
|
name: "Favorites",
|
||||||
|
type: "favorites",
|
||||||
|
isShared: true,
|
||||||
|
updationTime: T(1_705_000_000_000_000),
|
||||||
|
});
|
||||||
|
expect("key" in rec).toBe(false);
|
||||||
|
expect(JSON.parse(JSON.stringify(rec))).toEqual(rec);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("diffRecords", () => {
|
||||||
|
const at = 999;
|
||||||
|
|
||||||
|
it("returns undefined when nothing changed", () => {
|
||||||
|
const a = deriveRecords([collection(1)], [file(1, 1)]);
|
||||||
|
const b = deriveRecords([collection(1)], [file(1, 1)]);
|
||||||
|
expect(diffRecords(a, b, at)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports added and changed albums and photos and removals", () => {
|
||||||
|
const before = deriveRecords(
|
||||||
|
[collection(1), collection(2)],
|
||||||
|
[file(1, 1), file(2, 2)],
|
||||||
|
);
|
||||||
|
// Collection 2 is gone (album + its only file removed). Collection 1 is
|
||||||
|
// renamed (changed album), gains file 3, and file 1 is retitled.
|
||||||
|
const after = deriveRecords(
|
||||||
|
[collection(1, { name: "renamed" })],
|
||||||
|
[
|
||||||
|
file(1, 1, {
|
||||||
|
pubMagicMetadata: { editedName: "new title" },
|
||||||
|
}),
|
||||||
|
file(3, 1),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const change = diffRecords(before, after, at)!;
|
||||||
|
expect(change.refreshedAt).toBe(at);
|
||||||
|
expect(change.albumIDsRemoved).toEqual([2]);
|
||||||
|
expect(change.fileIDsRemoved).toEqual([2]);
|
||||||
|
expect(change.albumsChanged.map((a) => a.collectionID)).toEqual([1]);
|
||||||
|
expect(change.albumsChanged[0]!.name).toBe("renamed");
|
||||||
|
expect(
|
||||||
|
change.photosChanged.map((p) => p.fileID).sort((x, y) => x - y),
|
||||||
|
).toEqual([1, 3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
/**
|
||||||
|
* Tests for `Library.snapshot()` and `Library.subscribe()` (issue #43).
|
||||||
|
*
|
||||||
|
* These are the surface the GUI consumes across Electron IPC. `snapshot()` is
|
||||||
|
* synchronous — it reads the in-RAM store and projects it into plain records
|
||||||
|
* (no keys) — and `subscribe({ onChange })` delivers a `LibraryChange` whenever
|
||||||
|
* a background refresh actually changes the derived records. The contracts:
|
||||||
|
*
|
||||||
|
* 1. `snapshot()` deduplicates a file across memberships into one record with
|
||||||
|
* every album id, orders photos newest first, and carries no key material.
|
||||||
|
* 2. `subscribe` fires on a refresh that changes something, with the exact
|
||||||
|
* changed and removed sets for both albums and photos.
|
||||||
|
* 3. A refresh that changes nothing (an empty diff) fires no change.
|
||||||
|
* 4. `unsubscribe()` stops further delivery.
|
||||||
|
*
|
||||||
|
* The client is the same scripted mock used by the refresh-loop tests: no
|
||||||
|
* crypto, no network. Interval tests use a short real interval and `vi.waitFor`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { Library } from "../../src/library/index.js";
|
||||||
|
import type { LibraryChange } from "../../src/library/records.js";
|
||||||
|
import type { CollectionsPage, FilesPage } from "../../src/client.js";
|
||||||
|
import type { Collection, EnteFile } from "../../src/model/types.js";
|
||||||
|
|
||||||
|
const USER_ID = 42;
|
||||||
|
const FAST_INTERVAL = 0.02;
|
||||||
|
|
||||||
|
const collection = (
|
||||||
|
id: number,
|
||||||
|
updationTime: number,
|
||||||
|
name = `album-${id}`,
|
||||||
|
): Collection => ({
|
||||||
|
id,
|
||||||
|
ownerID: USER_ID,
|
||||||
|
key: new Uint8Array([id & 0xff]),
|
||||||
|
name,
|
||||||
|
type: "album",
|
||||||
|
updationTime,
|
||||||
|
isShared: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const file = (
|
||||||
|
id: number,
|
||||||
|
collectionID: number,
|
||||||
|
creationTime: number,
|
||||||
|
): EnteFile => ({
|
||||||
|
id,
|
||||||
|
collectionID,
|
||||||
|
ownerID: USER_ID,
|
||||||
|
key: new Uint8Array([id & 0xff]),
|
||||||
|
metadata: {
|
||||||
|
title: `file-${id}.jpg`,
|
||||||
|
fileType: "image",
|
||||||
|
creationTime,
|
||||||
|
modificationTime: creationTime,
|
||||||
|
},
|
||||||
|
file: { decryptionHeader: "aGVhZGVy" },
|
||||||
|
thumbnail: { decryptionHeader: "dGh1bWI=" },
|
||||||
|
updationTime: creationTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
class MockClient {
|
||||||
|
userID = USER_ID;
|
||||||
|
collectionsQueue: CollectionsPage[] = [];
|
||||||
|
filesByCollection = new Map<number, FilesPage[]>();
|
||||||
|
|
||||||
|
whoami(): { email: string; userID: number } {
|
||||||
|
return { email: "user@example.com", userID: this.userID };
|
||||||
|
}
|
||||||
|
|
||||||
|
async collectionsSince(args: {
|
||||||
|
sinceTime: number;
|
||||||
|
}): Promise<CollectionsPage> {
|
||||||
|
return (
|
||||||
|
this.collectionsQueue.shift() ?? {
|
||||||
|
collections: [],
|
||||||
|
deleted: [],
|
||||||
|
cursor: args.sinceTime,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async filesSince(args: {
|
||||||
|
collectionID: number;
|
||||||
|
collectionKey: Uint8Array;
|
||||||
|
sinceTime: number;
|
||||||
|
}): Promise<FilesPage> {
|
||||||
|
const queue = this.filesByCollection.get(args.collectionID);
|
||||||
|
return (
|
||||||
|
queue?.shift() ?? {
|
||||||
|
files: [],
|
||||||
|
deleted: [],
|
||||||
|
cursor: args.sinceTime,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
filesFor(collectionID: number, ...pages: FilesPage[]): void {
|
||||||
|
this.filesByCollection.set(collectionID, pages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Library.snapshot and Library.subscribe", () => {
|
||||||
|
let dir: string;
|
||||||
|
let cacheDirectory: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), "quak-snapshot-"));
|
||||||
|
cacheDirectory = join(dir, "cache");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("snapshot() dedupes across memberships, orders newest first, holds no keys", async () => {
|
||||||
|
const client = new MockClient();
|
||||||
|
client.collectionsQueue.push({
|
||||||
|
collections: [collection(1, 100), collection(2, 100)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 100,
|
||||||
|
});
|
||||||
|
// File 1001 is in both collections; 2002 only in collection 2 and newer.
|
||||||
|
client.filesFor(1, {
|
||||||
|
files: [file(1001, 1, 1_600_000_000_000_000)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 1_600_000_000_000_000,
|
||||||
|
});
|
||||||
|
client.filesFor(2, {
|
||||||
|
files: [
|
||||||
|
file(1001, 2, 1_600_000_000_000_000),
|
||||||
|
file(2002, 2, 1_800_000_000_000_000),
|
||||||
|
],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 1_800_000_000_000_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lib = await Library.open({ client, cacheDirectory });
|
||||||
|
try {
|
||||||
|
const snap = lib.snapshot();
|
||||||
|
|
||||||
|
// One record per fileID, newest first, both albums on the shared file.
|
||||||
|
expect(snap.photos.map((p) => p.fileID)).toEqual([2002, 1001]);
|
||||||
|
const shared = snap.photos.find((p) => p.fileID === 1001)!;
|
||||||
|
expect(shared.albumIDs).toEqual([1, 2]);
|
||||||
|
expect(shared.takenAt).toBe(1_600_000_000_000);
|
||||||
|
|
||||||
|
expect(snap.albums.map((a) => a.collectionID).sort()).toEqual([
|
||||||
|
1, 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Nothing carries key material; the whole snapshot is JSON-safe.
|
||||||
|
expect(JSON.parse(JSON.stringify(snap))).toEqual(snap);
|
||||||
|
for (const p of snap.photos) expect("key" in p).toBe(false);
|
||||||
|
for (const a of snap.albums) expect("key" in a).toBe(false);
|
||||||
|
} finally {
|
||||||
|
lib.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subscribe fires on a refresh change with the correct changed/removed sets", async () => {
|
||||||
|
const client = new MockClient();
|
||||||
|
client.collectionsQueue.push({
|
||||||
|
collections: [collection(1, 100), collection(2, 100)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 100,
|
||||||
|
});
|
||||||
|
client.filesFor(1, {
|
||||||
|
files: [file(1001, 1, 1_600_000_000_000_000)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 1_600_000_000_000_000,
|
||||||
|
});
|
||||||
|
client.filesFor(2, {
|
||||||
|
files: [file(2002, 2, 1_600_000_000_000_000)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 1_600_000_000_000_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lib = await Library.open({
|
||||||
|
client,
|
||||||
|
cacheDirectory,
|
||||||
|
refreshIntervalSeconds: FAST_INTERVAL,
|
||||||
|
});
|
||||||
|
const changes: LibraryChange[] = [];
|
||||||
|
const { unsubscribe } = lib.subscribe({
|
||||||
|
onChange: (c) => changes.push(c),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// Next refresh: collection 2 (and its file) tombstoned; collection 1
|
||||||
|
// gains file 1003.
|
||||||
|
client.filesFor(1, {
|
||||||
|
files: [file(1003, 1, 1_700_000_000_000_000)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 1_700_000_000_000_000,
|
||||||
|
});
|
||||||
|
client.collectionsQueue.push({
|
||||||
|
collections: [collection(1, 200)],
|
||||||
|
deleted: [2],
|
||||||
|
cursor: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(changes.length).toBeGreaterThan(0), {
|
||||||
|
timeout: 2000,
|
||||||
|
interval: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const change = changes[0]!;
|
||||||
|
expect(change.albumIDsRemoved).toEqual([2]);
|
||||||
|
expect(change.fileIDsRemoved).toEqual([2002]);
|
||||||
|
expect(change.photosChanged.map((p) => p.fileID)).toEqual([1003]);
|
||||||
|
expect(change.albumsChanged.map((a) => a.collectionID)).toEqual([
|
||||||
|
1,
|
||||||
|
]);
|
||||||
|
expect(change.refreshedAt).toBeGreaterThan(0);
|
||||||
|
} finally {
|
||||||
|
unsubscribe();
|
||||||
|
lib.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a refresh that changes nothing fires no change", async () => {
|
||||||
|
const client = new MockClient();
|
||||||
|
client.collectionsQueue.push({
|
||||||
|
collections: [collection(1, 100)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 100,
|
||||||
|
});
|
||||||
|
client.filesFor(1, {
|
||||||
|
files: [file(1001, 1, 1_600_000_000_000_000)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 1_600_000_000_000_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lib = await Library.open({
|
||||||
|
client,
|
||||||
|
cacheDirectory,
|
||||||
|
refreshIntervalSeconds: FAST_INTERVAL,
|
||||||
|
});
|
||||||
|
const changes: LibraryChange[] = [];
|
||||||
|
const { unsubscribe } = lib.subscribe({
|
||||||
|
onChange: (c) => changes.push(c),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// Let several empty-diff ticks pass; none may deliver a change.
|
||||||
|
await new Promise((r) => setTimeout(r, FAST_INTERVAL * 1000 * 6));
|
||||||
|
expect(changes).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
unsubscribe();
|
||||||
|
lib.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unsubscribe stops further delivery", async () => {
|
||||||
|
const client = new MockClient();
|
||||||
|
client.collectionsQueue.push({
|
||||||
|
collections: [collection(1, 100)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 100,
|
||||||
|
});
|
||||||
|
client.filesFor(1, {
|
||||||
|
files: [file(1001, 1, 1_600_000_000_000_000)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 1_600_000_000_000_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lib = await Library.open({
|
||||||
|
client,
|
||||||
|
cacheDirectory,
|
||||||
|
refreshIntervalSeconds: FAST_INTERVAL,
|
||||||
|
});
|
||||||
|
const changes: LibraryChange[] = [];
|
||||||
|
const { unsubscribe } = lib.subscribe({
|
||||||
|
onChange: (c) => changes.push(c),
|
||||||
|
});
|
||||||
|
unsubscribe();
|
||||||
|
try {
|
||||||
|
client.collectionsQueue.push({
|
||||||
|
collections: [collection(3, 300)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 300,
|
||||||
|
});
|
||||||
|
client.filesFor(3, {
|
||||||
|
files: [file(3003, 3, 1_700_000_000_000_000)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 1_700_000_000_000_000,
|
||||||
|
});
|
||||||
|
// The change lands in the store, but the cancelled subscriber sees
|
||||||
|
// nothing.
|
||||||
|
await vi.waitFor(
|
||||||
|
() => expect(lib.snapshot().albums.length).toBe(2),
|
||||||
|
{ timeout: 2000, interval: 5 },
|
||||||
|
);
|
||||||
|
expect(changes).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
lib.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user