vault-link/frontend/test-support/sync-audit.test.ts
2026-09-21 21:29:56 +01:00

878 lines
31 KiB
TypeScript

import assert from "node:assert/strict";
import { SUPPORTED_API_VERSION } from "../sync-client/src/consts";
import { PermanentSyncError } from "../sync-client/src/errors/errors";
import { test } from "node:test";
import type { Database } from "../sync-client/src/persistence/database";
import { emptyState } from "../sync-client/src/persistence/database";
import { SyncClient } from "../sync-client/src/sync-client";
import { toStoredSnapshot } from "../sync-client/src/sync-operations/content";
import { EventListeners } from "../sync-client/src/utils/data-structures/event-listeners";
import type { SyncService } from "../sync-client/src/services/sync-service";
import type { WebSocketManager } from "../sync-client/src/services/websocket-manager";
import { MemoryDisk, MemoryPersistence } from "./storage";
import { fixture, bytes, head } from "./sync-fixture";
test("ignored rename must not produce a duplicate submitted path", async () => {
const f = await fixture({ "a.md": "A", "b.md": "B" });
for (const [id, path] of Object.entries(f.database.state.local)) {
f.database.state.documents[id].base = {
...head(id, 1, id.toUpperCase()),
hash: f.database.state.documents[id].observedHash!
};
}
await f.disk.userRename("a.md", "ignored.md");
await f.syncer.syncLocallyUpdatedFile({
oldPath: "a.md",
relativePath: "ignored.md"
});
await f.disk.userRename("b.md", "a.md");
await f.syncer.syncLocallyUpdatedFile({
oldPath: "b.md",
relativePath: "a.md"
});
await f.settings.setSettings({ ignorePatterns: ["ignored.md"] });
await f.internals.scan();
f.internals.stopped = false;
await f.internals.preparePush();
const p = f.database.state.pending;
assert(p?.type === "fileManifest");
assert.equal(
new Set(Object.values(p.request.entries)).size,
Object.keys(p.request.entries).length
);
});
test("ignored untracked ancestor must remain in place", async () => {
const f = await fixture(
{},
{ getDocumentVersionContent: async () => bytes("remote") }
);
await f.disk.userWrite("private", bytes("LOCAL PRIVATE"));
await f.settings.setSettings({ ignorePatterns: ["private"] });
f.database.state.remoteHeads.a = head("a", 2, "remote");
await f.internals.incorporateFileManifest({
fileManifestId: 3,
entries: { a: "private/note.md" }
});
f.internals.stopped = false;
await f.internals.preparePush();
assert.deepEqual(await f.disk.readSnapshot("private"), {
content: bytes("LOCAL PRIVATE")
});
});
test("oversized untracked file must remain in place", async () => {
const f = await fixture(
{},
{ getDocumentVersionContent: async () => bytes("R") }
);
await f.disk.userWrite("a.md", bytes("LOCAL OVERSIZED"));
await f.settings.setSettings({ maxFileSizeMB: 5 / (1024 * 1024) });
f.database.state.remoteHeads.a = head("a", 2, "R");
await f.internals.incorporateFileManifest({
fileManifestId: 3,
entries: { a: "a.md" }
});
assert.deepEqual(
(await f.disk.readSnapshot("a.md"))?.content,
bytes("LOCAL OVERSIZED")
);
});
test("rejected unknown success must use submitted content as merge base", async () => {
const f = await fixture(
{ "a.md": "base" },
{
putFileContent: async () => {
throw new PermanentSyncError("HTTP 413");
},
getDocumentVersionContent: async ({ vaultUpdateId }) =>
bytes(
vaultUpdateId === 1
? "base"
: vaultUpdateId === 2
? "base A"
: "base A REMOTE"
)
}
);
f.database.state.documents.a.base = {
...head("a", 1, "base"),
hash: f.database.state.documents.a.observedHash!
};
f.database.state.lastSeenUpdateId = 1;
await f.disk.userWrite("a.md", bytes("base A"));
f.internals.stopped = false;
await f.internals.preparePush();
const req = f.database.state.pending!.request.requestId;
await f.internals.finishPending();
await f.disk.userWrite("a.md", bytes("base"));
await f.internals.incorporateEventBatch({
headEventId: 3,
events: [
{
eventId: 2,
requestId: req,
type: "content",
document: head("a", 2, "base A")
},
{
eventId: 3,
requestId: "other",
type: "content",
document: head("a", 3, "base A REMOTE")
}
]
});
const result = Buffer.from(
(await f.disk.readSnapshot("a.md"))!.content
).toString();
assert.equal(result, "base REMOTE");
});
test("bootstrap adopts same-path identity even after multiple notifications", async () => {
const f = await fixture({});
f.database.state.initialized = false;
f.database.state.fileManifest = { fileManifestId: 0, entries: {} };
await f.disk.userWrite("a.md", bytes("local a"));
await f.syncer.syncLocallyCreatedFile("a.md");
await f.disk.userWrite("b.md", bytes("local b"));
await f.syncer.syncLocallyCreatedFile("b.md");
const initial = {
headEventId: 2,
fileManifest: { fileManifestId: 2, entries: { remoteA: "a.md" } },
documents: [head("remoteA", 1, "remote a")]
};
await f.internals.scan(initial);
assert.equal(f.database.state.local.remoteA, "a.md");
});
test("oversized remote head must not cause identical stale uploads forever", async () => {
let attempts = 0;
const remoteText = "R".repeat(20);
const f = await fixture(
{ "a.md": "edit" },
{
putFileContent: async () => {
attempts++;
return {
type: "StaleBase",
...head("a", 2, remoteText),
contentBase64: Buffer.from(remoteText).toString("base64")
};
}
}
);
f.database.state.documents.a.base = {
...head("a", 1, "base"),
hash: (await toStoredSnapshot({ content: bytes("base") })).hash
};
f.database.state.remoteHeads.a = head("a", 2, remoteText);
await f.settings.setSettings({ maxFileSizeMB: 10 / (1024 * 1024) });
f.internals.stopped = false;
// Even a small local edit must wait until its required remote base is readable.
assert.equal(await f.internals.preparePush(), false);
assert.equal(attempts, 0);
assert.equal(f.database.state.pending, undefined);
});
test("local undo of accepted-but-rejected manifest must survive replay", async () => {
const f = await fixture(
{ "a.md": "A" },
{
pushFileManifest: async () => {
throw new PermanentSyncError("HTTP 413");
},
getDocumentVersionContent: async () => bytes("A")
}
);
f.database.state.lastSeenUpdateId = 1;
f.database.state.documents.a.base = {
...head("a", 1, "A"),
hash: f.database.state.documents.a.observedHash!
};
await f.disk.userRename("a.md", "b.md");
await f.syncer.syncLocallyUpdatedFile({
oldPath: "a.md",
relativePath: "b.md"
});
await f.internals.scan();
f.internals.stopped = false;
await f.internals.preparePush();
const req = f.database.state.pending!.request.requestId;
f.internals.stopped = true;
await f.internals.finishPending();
await f.disk.userRename("b.md", "a.md");
await f.syncer.syncLocallyUpdatedFile({
oldPath: "b.md",
relativePath: "a.md"
});
await f.internals.incorporateEventBatch({
headEventId: 2,
events: [
{
eventId: 2,
requestId: req,
type: "fileManifest",
fileManifest: { fileManifestId: 2, entries: { a: "b.md" } }
}
]
});
assert.equal(f.database.state.local.a, "a.md");
});
test("a settings update resumes the enabled engine", async () => {
const f = await settingsLifecycleFixture();
try {
await f.client.setSettings({ maxFileSizeMB: 20 });
assert.equal(f.client.getSettings().maxFileSizeMB, 20);
assert.equal(f.store.snapshot().settings?.maxFileSizeMB, 20);
const before = f.reads();
await f.client.syncLocallyUpdatedFile({ relativePath: "absent.md" });
await f.client.waitUntilFinished();
assert(
f.reads() > before,
"notifications must still wake the enabled engine"
);
} finally {
await f.client.destroy();
}
});
test("a dirty cursor update must invalidate old precise positions", async () => {
const { CursorTracker } = await import(
"../sync-client/src/sync-operations/cursor-tracker"
);
const { FileChangeNotifier } = await import(
"../sync-client/src/sync-operations/file-change-notifier"
);
const f = await fixture({ "a.md": "base" });
f.database.state.documents.a.base = {
...head("a", 1, "base"),
hash: f.database.state.documents.a.observedHash!
};
const ws = {
onWebSocketStatusChanged: new EventListeners(),
onRemoteCursorsUpdateReceived: new EventListeners(),
updateLocalCursors: () => {}
};
const tracker = new CursorTracker(
f.database,
ws as unknown as WebSocketManager,
f.files,
new FileChangeNotifier()
);
let latest: import("../sync-client/src/types/maybe-outdated-client-cursors").MaybeOutdatedClientCursors[] =
[];
tracker.onRemoteCursorsUpdated.add((cursors) => {
latest = cursors;
});
const clean = {
userName: "other",
deviceId: "other",
documentsWithCursors: [
{
document_id: "a",
relative_path: "a.md",
vault_update_id: 1,
cursors: [{ start: 0, end: 0 }]
}
]
};
await ws.onRemoteCursorsUpdateReceived.triggerAsync([clean]);
await ws.onRemoteCursorsUpdateReceived.triggerAsync([
{
...clean,
documentsWithCursors: [
{
...clean.documentsWithCursors[0],
vault_update_id: null,
cursors: [{ start: 100, end: 100 }]
}
]
}
]);
assert.equal(latest[0]?.isOutdated, true);
tracker.reset();
});
test("silent open WebSocket must eventually trigger catchup or reconnect", async (context) => {
context.mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
let opens = 0,
eventReads = 0;
class SilentWebSocket {
static OPEN = 1;
readyState = 1;
onopen: ((event: object) => void) | null = null;
onclose: ((event: { code: number; reason: string }) => void) | null =
null;
onmessage: ((event: { data: string }) => void) | null = null;
onerror: ((event: object) => void) | null = null;
constructor(_url: string | URL) {
opens++;
queueMicrotask(() => this.onopen?.({}));
}
send(_message: string) {}
close() {
this.readyState = 3;
this.onclose?.({ code: 1000, reason: "" });
}
}
const store = new MemoryPersistence({
settings: {
isSyncEnabled: true,
remoteUri: "http://silent.test",
vaultName: "test",
token: "test",
syncIntervalMs: 0
}
});
const client = await SyncClient.create({
fs: new MemoryDisk(),
persistence: store,
webSocket: SilentWebSocket as unknown as typeof WebSocket,
fetch: async (input) => {
const path = String(input);
if (path.endsWith("/ping"))
return Response.json({
supportedApiVersion: SUPPORTED_API_VERSION,
isAuthenticated: true,
mergeableFileExtensions: ["md"],
serverVersion: "test"
});
if (path.endsWith("/vault-snapshot"))
return Response.json({
headEventId: 0,
fileManifest: { fileManifestId: 0, entries: {} },
documents: []
});
if (path.includes("/events-since")) {
eventReads++;
return Response.json({ headEventId: 0, events: [] });
}
throw new Error("Unexpected request " + path);
}
});
try {
await client.start();
const before = eventReads;
context.mock.timers.tick(45_000);
context.mock.timers.tick(3500);
for (let i = 0; i < 30; i++) await Promise.resolve();
assert(opens > 1 || eventReads > before);
} finally {
await client.destroy();
}
});
test("oversized files should be filtered before reading complete snapshots", async () => {
const f = await fixture({});
await f.disk.userWrite("huge.bin", bytes("too big"));
await f.settings.setSettings({ maxFileSizeMB: 1 / (1024 * 1024) });
let reads = 0;
f.disk.boundary = (label) => {
if (label === "read:snapshot:huge.bin") reads++;
};
await f.internals.scan();
assert.equal(reads, 0);
});
test("permanent destination failure replans the affected file and finishes the batch", async () => {
const f = await fixture({ "a.md": "A", "b.md": "B" });
f.disk.boundary = (label) => {
if (label === "before:mkdir:unsupported")
throw Object.assign(new Error("filesystem path limit"), {
code: "ENAMETOOLONG"
});
};
const next = structuredClone(f.database.state);
next.local = { a: "unsupported/a.md", b: "good.md" };
next.fileManifest = { fileManifestId: 2, entries: { ...next.local } };
await f.files.apply(next);
await f.settings.setSettings({ ignorePatterns: ["unsupported/**"] });
assert.deepEqual(
[...f.disk.userFiles().values()]
.map((b) => Buffer.from(b).toString())
.sort(),
["A", "B"]
);
});
test("conflict allocation terminates when a truncated extension ends in a space", async () => {
const { spawnSync } = await import("node:child_process");
const modulePath = require.resolve(
"../sync-client/src/utils/portable-path"
);
const result = spawnSync(
process.execPath,
[
"--require",
require.resolve("tsx/cjs"),
"-e",
`
const {allocatePortablePath,validatePortablePaths} = require(${JSON.stringify(modulePath)});
const name = 'a.' + 'x'.repeat(62) + ' ' + 'x'.repeat(100);
const allocated = allocatePortablePath(name, 'new', {old:name});
validatePortablePaths([name, allocated]);
if (Buffer.byteLength(allocated) > 255) process.exit(2);
`
],
{ timeout: 3000, encoding: "utf8" }
);
assert.equal(result.status, 0, String(result.error ?? result.stderr));
});
for (const interrupted of [false, true]) {
test(`paged catchup folds edits and reversions${interrupted ? " again after an interrupted fetch" : ""}`, async () => {
const first = {
headEventId: 3,
endEventId: 2,
events: [
{
eventId: 2,
requestId: "remote-1",
type: "content" as const,
document: head("a", 2, "base REMOTE")
}
]
};
const last = {
headEventId: 3,
endEventId: 3,
events: [
{
eventId: 3,
requestId: "remote-2",
type: "content" as const,
document: head("a", 3, "base")
}
]
};
let fail = interrupted;
let f: Awaited<ReturnType<typeof fixture>>;
const fetchedAfter: number[] = [];
const service: Partial<SyncService> = {
getDocumentVersionContent: async () => bytes("base"),
events: async (after) => {
fetchedAfter.push(after);
if (after === 1) return first;
assert.equal(after, 2);
assert.equal(f.database.state.lastSeenUpdateId, 1);
assert.deepEqual(
f.persistence.snapshot(),
saved,
"partial pages must not save metadata"
);
assert.deepEqual(
(await f.disk.readSnapshot("a.md"))?.content,
bytes("base LOCAL")
);
if (fail) {
fail = false;
throw new Error("interrupted page fetch");
}
return last;
}
};
f = await fixture({ "a.md": "base LOCAL" }, service);
f.database.state.documents.a.base = {
...head("a", 1, "base"),
hash: (await toStoredSnapshot({ content: bytes("base") })).hash
};
f.database.state.remoteHeads.a = head("a", 1, "base");
f.database.state.lastSeenUpdateId = 1;
await f.database.save();
const saved = f.persistence.snapshot();
const catchUp = async () =>
f.internals.incorporateEventBatch(
await service.events!(f.database.state.lastSeenUpdateId)
);
if (interrupted) {
await assert.rejects(catchUp(), /interrupted page fetch/);
f.disk.crash(true);
f = await fixture({}, service, f);
}
await catchUp();
assert.deepEqual(fetchedAfter, interrupted ? [1, 2, 1, 2] : [1, 2]);
assert.equal(f.database.state.lastSeenUpdateId, 3);
assert.deepEqual(
(await f.disk.readSnapshot("a.md"))?.content,
bytes("base LOCAL")
);
});
}
test("an ignored occupant arriving after the scan keeps its path and privacy", async () => {
let f: Awaited<ReturnType<typeof fixture>>;
f = await fixture(
{},
{
getDocumentVersionContent: async () => {
await f.disk.userWrite("private", bytes("SECRET"));
return bytes("remote");
}
}
);
await f.settings.setSettings({ ignorePatterns: ["private"] });
f.database.state.remoteHeads.a = head("a", 2, "remote");
await f.internals.incorporateFileManifest({
fileManifestId: 3,
entries: { a: "private/note.md" }
});
assert.deepEqual(
(await f.disk.readSnapshot("private"))?.content,
bytes("SECRET")
);
f.internals.stopped = false;
await f.internals.preparePush();
const { pending } = f.database.state;
assert(
pending?.type !== "content" ||
Buffer.from(pending.snapshot.contentBase64, "base64").toString() !==
"SECRET"
);
});
test("content notifications during every snapshot do not starve a complete namespace scan", async () => {
const f = await fixture({ "a.md": "A", "b.md": "B" });
let notifications = 0;
f.disk.boundary = async (label) => {
if (
label.startsWith("read:snapshot:") &&
!label.includes(".vault-link-sync")
) {
notifications++;
await f.syncer.syncLocallyUpdatedFile({
relativePath: label.slice("read:snapshot:".length)
});
}
};
await f.internals.scan();
assert.equal(notifications, 2);
assert.deepEqual(f.database.state.local, { a: "a.md", b: "b.md" });
});
for (const boundary of ["before:save", "durable:save"]) {
test(`receipt incorporation after paged replay survives ${boundary} and process replacement`, async () => {
const service: Partial<SyncService> = {
events: async (after) => {
assert.equal(after, 2);
return page;
},
putFileContent: async () => {
throw new PermanentSyncError("HTTP 413");
},
getDocumentVersionContent: async ({ vaultUpdateId }) =>
bytes(
vaultUpdateId === 1
? "base"
: vaultUpdateId === 2
? "base A"
: "base A REMOTE"
)
};
let f = await fixture({ "a.md": "base" }, service);
f.database.state.documents.a.base = {
...head("a", 1, "base"),
hash: f.database.state.documents.a.observedHash!
};
f.database.state.lastSeenUpdateId = 1;
await f.disk.userWrite("a.md", bytes("base A"));
f.internals.stopped = false;
await f.internals.preparePush();
const { requestId } = f.database.state.pending!.request;
await f.internals.finishPending();
await f.disk.userWrite("a.md", bytes("base"));
const first = {
headEventId: 3,
endEventId: 2,
events: [
{
eventId: 2,
requestId,
type: "content" as const,
document: head("a", 2, "base A")
}
]
};
const page = {
headEventId: 3,
endEventId: 3,
events: [
{
eventId: 3,
requestId: "other",
type: "content" as const,
document: head("a", 3, "base A REMOTE")
}
]
};
f.persistence.boundary = (label) => {
if (label === boundary) throw new Error("injected crash");
};
await assert.rejects(
f.internals.incorporateEventBatch(first),
/injected crash/
);
f.persistence.boundary = () => {};
f.disk.crash(true);
f = await fixture({}, service, f);
assert.equal(f.database.state.lastSeenUpdateId, 1);
await f.internals.incorporateEventBatch(first);
assert.equal(f.database.state.lastSeenUpdateId, 3);
assert.deepEqual(
(await f.disk.readSnapshot("a.md"))?.content,
bytes("base REMOTE")
);
});
}
test("initial vault settings bind the engine and survive restart", async () => {
const disk = new MemoryDisk();
const persistence = new MemoryPersistence({
settings: {
remoteUri: "http://test",
vaultName: "old",
isSyncEnabled: false
}
});
class Socket {
static OPEN = 1;
readyState = 3;
onopen = null;
onclose = null;
onerror = null;
onmessage = null;
send() {}
close() {}
}
const options = {
fs: disk,
persistence,
webSocket: Socket as unknown as typeof WebSocket,
fetch: async (input: RequestInfo | URL) => {
if (String(input).endsWith("/ping"))
return Response.json({
supportedApiVersion: SUPPORTED_API_VERSION,
isAuthenticated: true,
mergeableFileExtensions: ["md"],
serverVersion: "test"
});
if (String(input).endsWith("/vault-snapshot"))
return Response.json({
headEventId: 0,
fileManifest: { fileManifestId: 0, entries: {} },
documents: []
});
return Response.json({ headEventId: 0, events: [] });
}
};
let client = await SyncClient.create(options);
try {
await client.setSettings({ vaultName: "new", isSyncEnabled: true });
await client.start();
await client.destroy();
client = await SyncClient.create(options);
assert.equal(client.getSettings().vaultName, "new");
await client.start();
} finally {
await client.destroy();
}
});
test("disabling sync persists across reset without resuming the transport", async () => {
const f = await settingsLifecycleFixture();
try {
const before = f.reads();
await f.client.setSettings({ isSyncEnabled: false });
assert.equal(f.store.snapshot().settings?.isSyncEnabled, false);
await f.client.reset();
assert.equal(f.client.getSettings().isSyncEnabled, false);
assert.equal(f.reads(), before);
} finally {
await f.client.destroy();
}
});
for (const receipt of [false, true]) {
test(`${receipt ? "replayed" : "direct"} upload acknowledgement survives a local deletion`, async () => {
const f = await fixture(
{ "a.md": "base" },
{
putFileContent: async () => {
if (receipt)
throw new PermanentSyncError(
"HTTP 413 after lost success"
);
return { type: "Accepted", ...head("a", 2, "edited") };
}
}
);
f.database.state.documents.a.base = {
...head("a", 1, "base"),
hash: f.database.state.documents.a.observedHash!
};
f.database.state.lastSeenUpdateId = 1;
await f.disk.userWrite("a.md", bytes("edited"));
f.internals.stopped = false;
await f.internals.preparePush();
const { requestId } = f.database.state.pending!.request;
if (receipt) await f.internals.finishPending();
// Recovery folds queued deletions into the durable namespace before
// an outstanding request or its delayed receipt is handled.
f.internals.stopped = true;
await f.disk.userDelete("a.md");
await f.syncer.syncLocallyDeletedFile("a.md");
await f.internals.scan();
assert.deepEqual(f.database.state.local, {});
if (receipt) {
await f.internals.incorporateEventBatch({
headEventId: 2,
events: [
{
eventId: 2,
requestId,
type: "content",
document: head("a", 2, "edited")
}
]
});
assert.equal(f.database.state.lastSeenUpdateId, 2);
assert.equal(f.database.state.unconfirmed?.length ?? 0, 0);
} else await f.internals.finishPending();
assert.equal(f.database.state.pending, undefined);
assert.deepEqual(f.database.state.local, {});
assert.equal(await f.disk.readSnapshot("a.md"), undefined);
assert.equal(f.database.state.documents.a.base?.vaultUpdateId, 2);
});
}
async function settingsLifecycleFixture() {
let eventReads = 0;
let onClose = () => {};
class Socket {
static OPEN = 1;
readyState = 1;
onopen: (() => void) | null = null;
onclose: ((event: { code: number; reason: string }) => void) | null =
null;
constructor() {
queueMicrotask(() => this.onopen?.());
}
send() {}
close() {
onClose();
this.readyState = 3;
this.onclose?.({ code: 1000, reason: "" });
}
}
const store = new MemoryPersistence({
settings: {
remoteUri: "http://test",
vaultName: "test",
isSyncEnabled: true
}
});
const client = await SyncClient.create({
fs: new MemoryDisk(),
persistence: store,
webSocket: Socket as unknown as typeof WebSocket,
fetch: async (input) => {
const path = String(input);
if (path.endsWith("/ping"))
return Response.json({
supportedApiVersion: SUPPORTED_API_VERSION,
isAuthenticated: true,
mergeableFileExtensions: ["md"],
serverVersion: "test"
});
if (path.endsWith("/vault-snapshot"))
return Response.json({
headEventId: 0,
fileManifest: { fileManifestId: 0, entries: {} },
documents: []
});
if (path.includes("/events-since")) {
eventReads++;
return Response.json({ headEventId: 0, events: [] });
}
throw new Error("Unexpected request " + path);
}
});
await client.start();
return {
client,
store,
reads: () => eventReads,
onClose: (fn: () => void) => {
onClose = fn;
}
};
}
for (const boundary of ["before:save", "durable:save"]) {
test(`settings recovery resumes after the rejection-reset save fails at ${boundary}`, async () => {
const f = await settingsLifecycleFixture();
try {
const { database } = f.client as unknown as { database: Database };
await database.commit({
...database.state,
rejectedManifest: {
entries: { retired: "old.md" },
message: "retired rejection"
}
});
let saves = 0,
failed = false;
f.store.boundary = (label) => {
if (label === "before:save") saves++;
if (saves === 2 && label === boundary && !failed) {
failed = true;
throw new Error("rejection-reset save failed");
}
};
await assert.rejects(
f.client.setSettings({ maxFileSizeMB: 20 }),
/rejection-reset save failed/
);
assert(failed);
assert.equal(f.client.getSettings().maxFileSizeMB, 20);
const before = f.reads();
await f.client.syncLocallyUpdatedFile({
relativePath: "absent.md"
});
await f.client.waitUntilFinished();
assert(
f.reads() > before,
"the enabled engine must resume after any settings-phase save failure"
);
} finally {
await f.client.destroy();
}
});
}
test("a caller cannot mutate settings while transports are being paused", async () => {
const f = await settingsLifecycleFixture();
const change = {
maxFileSizeMB: 20,
vaultName: "test",
ignorePatterns: ["private/**"]
};
try {
f.onClose(() => {
change.vaultName = "wrong-vault";
change.ignorePatterns.push("**");
});
await f.client.setSettings(change);
assert.equal(f.client.getSettings().vaultName, "test");
assert.deepEqual(f.client.getSettings().ignorePatterns, ["private/**"]);
assert.equal(
f.store.snapshot().database?.vaultKey,
JSON.stringify(["http://test", "test"])
);
} finally {
await f.client.destroy();
}
});