Update tests

This commit is contained in:
Andras Schmelczer 2026-09-27 19:31:44 +01:00
commit 4e315e052a
17 changed files with 1385 additions and 147 deletions

View file

@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { Database, createEmptyDatabase } from "./database";
test("current state keeps absent optional fields absent across save and reload", async () => {
const initial = createEmptyDatabase("vault");
initial.local = { local: "local.md", remote: "remote.md" };
initial.documents = {
local: { observedHash: "local" },
remote: { remote: { vaultUpdateId: 1, contentSize: 3 } },
empty: {}
};
let saved = structuredClone(initial);
const restart = (): Database =>
new Database(
saved,
async (next) => {
saved = structuredClone(next);
},
"vault",
async () => structuredClone(saved)
);
const db = restart();
assert.deepEqual(db.state, initial);
await db.save();
assert.deepEqual(saved, initial);
assert.deepEqual(restart().state, initial);
});
test("current reset state is restored after an uncertain save", async () => {
const saved = createEmptyDatabase("vault");
saved.local = { note: "note.md" };
saved.documents.note = {
base: { vaultUpdateId: null, hash: "clean" },
observedHash: "clean"
};
const db = new Database(
undefined,
async () => {
throw new Error("interrupted");
},
"vault",
async () => structuredClone(saved)
);
await assert.rejects(db.save(), /interrupted/);
await db.recoverPersistence();
assert.deepEqual(db.state, saved);
});

View file

@ -9,7 +9,7 @@ describe("Settings logging", () => {
const settings = new Settings(
logger,
{ token: "loaded-secret" },
async () => {}
async () => undefined
);
await settings.setSettings({ token: "updated-secret" });
@ -43,7 +43,7 @@ it("owns settings arrays instead of sharing them with callers and listeners", as
const settings = new Settings(
new Logger(),
{ ignorePatterns: original },
async () => {}
async () => undefined
);
original.push("external mutation");
assert.deepEqual(settings.getSettings().ignorePatterns, ["original"]);
@ -67,7 +67,9 @@ it("file eligibility follows successfully saved settings", async () => {
maxFileSizeMB: 1
},
async () => {
if (fail) throw new Error("save failed");
if (fail) {
throw new Error("save failed");
}
}
);
assert(settings.isIgnored("private/note.md"));

View file

@ -1,3 +1,4 @@
import { awaitAll } from "../utils/await-all";
import assert from "node:assert/strict";
import { test } from "node:test";
import { ServerConfig } from "./server-config";
@ -12,28 +13,31 @@ const config = {
mergeableFileExtensions: ["md"],
serverVersion: "test"
};
const make = (fetch: typeof globalThis.fetch) =>
const make = (fetch: typeof globalThis.fetch): ServerConfig =>
new ServerConfig(
new SyncService(
"device",
new Settings(
new Logger(),
{ remoteUri: "http://test" },
async () => {}
async () => undefined
),
fetch,
{ get: () => undefined, save: async () => {} }
{ get: () => undefined, save: async () => undefined }
)
);
test("configuration shares one validated request and retries failures", async () => {
let calls = 0;
const server = make(async () => {
if (++calls === 1) throw new Error("offline");
if (++calls === 1) {
throw new Error("offline");
}
return Response.json(config);
});
await assert.rejects(server.getConfig(), /offline/);
const [a, b] = await Promise.all([server.getConfig(), server.getConfig()]);
const [a, b] = await awaitAll([server.getConfig(), server.getConfig()]);
assert.equal(a, b);
assert.equal(calls, 2);
await server.getConfig();
@ -74,6 +78,7 @@ for (const fail of [false, true]) {
);
await old;
}
assert.equal(await server.getConfig(), current);
assert.equal(calls, 2);
});

View file

@ -5,7 +5,7 @@ import { Logger } from "../tracing/logger";
import { SyncResetError } from "../errors/errors";
import { SyncService } from "./sync-service";
const settings = () =>
const settings = (): Settings =>
new Settings(
new Logger(),
{
@ -13,15 +13,26 @@ const settings = () =>
vaultName: "test",
requestTimeoutMs: 1000
},
async () => { }
async () => undefined
);
test("paused sync sends no requests, while connection checks still work", async () => {
test("paused sync sends no requests, while connection checks still work", async (): Promise<void> => {
let requests = 0;
const service = new SyncService("device", settings(), async () => {
requests++;
return Response.json({ headEventId: 0, events: [] });
}, { get: () => undefined, save: async () => { } });
const service = new SyncService(
"device",
settings(),
async () => {
requests++;
return Response.json(
{ headEventId: 0, endEventId: 0, events: [] },
{ headers: { "X-Vault-Link-History": "0:empty" } }
);
},
{
get: (): undefined => undefined,
save: async (): Promise<void> => undefined
}
);
await assert.rejects(service.getEvents(0), SyncResetError);
assert.equal(requests, 0);
await service.ping();
@ -31,7 +42,7 @@ test("paused sync sends no requests, while connection checks still work", async
assert.equal(requests, 2);
});
test("pause aborts requests and fences late responses from an injected fetch", async () => {
test("pause aborts requests and fences late responses from an injected fetch", async (): Promise<void> => {
const response = Promise.withResolvers<Response>();
const signals: AbortSignal[] = [];
const saved: (string | undefined)[] = [];
@ -39,14 +50,18 @@ test("pause aborts requests and fences late responses from an injected fetch", a
"device",
settings(),
async (_, init) => {
signals.push(init!.signal!);
assert.ok(init?.signal);
signals.push(init.signal);
return signals.length === 1
? response.promise
: Response.json({ headEventId: 0, events: [] });
: Response.json(
{ headEventId: 0, endEventId: 0, events: [] },
{ headers: { "X-Vault-Link-History": "0:empty" } }
);
},
{
get: () => undefined,
save: async (value) => {
get: (): undefined => undefined,
save: async (value): Promise<void> => {
saved.push(value);
}
}
@ -55,10 +70,10 @@ test("pause aborts requests and fences late responses from an injected fetch", a
const first = service.getEvents(0);
service.pause();
await assert.rejects(first, SyncResetError);
assert.equal(signals[0].aborted, true);
assert.equal(signals[0]?.aborted, true);
service.resume();
await service.getEvents(0);
assert.equal(signals[1].aborted, false);
assert.equal(signals[1]?.aborted, false);
response.resolve(
Response.json(
{},
@ -66,53 +81,57 @@ test("pause aborts requests and fences late responses from an injected fetch", a
)
);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(saved, []);
assert.deepEqual(saved, ["0:empty"]);
});
for (const binary of [false, true]) {
test(`pause interrupts a stalled ${binary ? "binary" : "JSON"} response body`, async () => {
const headersSaved = Promise.withResolvers<void>();
let body!: ReadableStreamDefaultController<Uint8Array>;
test(`pause interrupts a stalled ${binary ? "binary" : "JSON"} response body`, async (): Promise<void> => {
const headersSaved = Promise.withResolvers<undefined>();
const bodyReady =
Promise.withResolvers<
ReadableStreamDefaultController<Uint8Array>
>();
const service = new SyncService(
"device",
settings(),
async () =>
new Response(
new ReadableStream({
start(controller) {
body = controller;
new ReadableStream<Uint8Array>({
start(controller): void {
bodyReady.resolve(controller);
}
}),
{ headers: { "X-Vault-Link-History": "1:head" } }
),
{
get: () => undefined,
save: async () => {
headersSaved.resolve();
get: (): undefined => undefined,
save: async (): Promise<void> => {
headersSaved.resolve(undefined);
}
}
);
service.resume();
const request = binary
? service.getDocumentVersionContent({
documentId: "a",
vaultUpdateId: 1
})
documentId: "a",
vaultUpdateId: 1
})
: service.getEvents(0);
await headersSaved.promise;
await new Promise((resolve) => setImmediate(resolve));
service.pause();
await assert.rejects(request, SyncResetError);
const body = await bodyReady.promise;
body.enqueue(new TextEncoder().encode("{}"));
body.close();
});
}
test("pause waits for a checkpoint save already in progress", async () => {
const saving = Promise.withResolvers<void>();
const release = Promise.withResolvers<void>();
test("pause waits for a checkpoint save already in progress", async (): Promise<void> => {
const saving = Promise.withResolvers<undefined>();
const release = Promise.withResolvers<undefined>();
let settled = false;
let checkpoint: string | undefined;
let checkpoint: string | undefined = undefined;
const service = new SyncService(
"device",
settings(),
@ -124,9 +143,9 @@ test("pause waits for a checkpoint save already in progress", async () => {
}
),
{
get: () => checkpoint,
save: async (value) => {
saving.resolve();
get: (): string | undefined => checkpoint,
save: async (value): Promise<void> => {
saving.resolve(undefined);
await release.promise;
checkpoint = value;
}
@ -140,13 +159,13 @@ test("pause waits for a checkpoint save already in progress", async () => {
service.pause();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(settled, false);
release.resolve();
release.resolve(undefined);
await assert.rejects(request, SyncResetError);
assert.equal(checkpoint, "1:head");
});
test("history saves only new checkpoints, including a changed token at the same event ID", async () => {
let checkpoint: string | undefined;
test("history saves only new checkpoints, including a changed token at the same event ID", async (): Promise<void> => {
let checkpoint: string | undefined = undefined;
let responseCheckpoint = "1:first";
const saved: (string | undefined)[] = [];
const service = new SyncService(
@ -158,8 +177,8 @@ test("history saves only new checkpoints, including a changed token at the same
{ headers: { "X-Vault-Link-History": responseCheckpoint } }
),
{
get: () => checkpoint,
save: async (value) => {
get: (): string | undefined => checkpoint,
save: async (value): Promise<void> => {
checkpoint = value;
saved.push(value);
}
@ -177,5 +196,36 @@ test("history saves only new checkpoints, including a changed token at the same
responseCheckpoint = value;
await service.getEvents(0);
}
assert.deepEqual(saved, ["1:first", "2:next", "2:replacement"]);
});
for (const checkpoint of [undefined, ""]) {
test(`successful sync responses reject ${checkpoint === undefined ? "missing" : "empty"} history checkpoints`, async () => {
const service = new SyncService(
"device",
settings(),
async () =>
Response.json(
{ headEventId: 0, endEventId: 0, events: [] },
{
headers:
checkpoint === undefined
? {}
: { "X-Vault-Link-History": checkpoint }
}
),
{
get: (): undefined => undefined,
save: async (): Promise<void> => {
assert.fail("must not save a missing checkpoint");
}
}
);
service.resume();
await assert.rejects(
service.getEvents(0),
/Missing server history checkpoint/
);
});
}

View file

@ -1,9 +1,11 @@
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
import { describe, it, beforeEach } from "node:test";
import assert from "node:assert";
import { createServer } from "node:net";
import { WebSocketManager } from "./websocket-manager";
import type { Logger } from "../tracing/logger";
import type { Settings } from "../persistence/settings";
import { WEBSOCKET_RECEIVE_TIMEOUT_MS } from "../consts";
import { Logger } from "../tracing/logger";
import { Settings } from "../persistence/settings";
// eslint-disable-next-line @typescript-eslint/no-require-imports
const WebSocket = require("ws") as typeof globalThis.WebSocket;
@ -39,7 +41,7 @@ class MockWebSocket {
public sentMessages: string[] = [];
public constructor(public url: string) {
public constructor(public url: string | URL) {
setTimeout(() => {
if (this.readyState === WebSocket.CONNECTING) {
this.readyState = WebSocket.OPEN;
@ -52,6 +54,7 @@ class MockWebSocket {
if (this.readyState !== WebSocket.OPEN) {
throw new Error("WebSocket is not open");
}
this.sentMessages.push(data);
}
@ -98,6 +101,7 @@ describe("WebSocketManager", () => {
const noop = (): void => {
// Intentionally empty for mock
};
mockLogger = {
info: createMockFn(noop),
warn: createMockFn(noop),
@ -129,30 +133,24 @@ describe("WebSocketManager", () => {
await new Promise((resolve) => setTimeout(resolve, 50));
const { outstandingPromises } = manager as unknown as {
outstandingPromises: Promise<unknown>[];
outstandingPromises: Set<Promise<void>>;
};
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
.webSocket;
mockWs.simulateMessage({
type: "vaultEvents",
headEventId: 0,
events: []
type: "vaultChanged"
});
mockWs.simulateMessage({
type: "vaultEvents",
headEventId: 0,
events: []
type: "vaultChanged"
});
mockWs.simulateMessage({
type: "vaultEvents",
headEventId: 0,
events: []
type: "vaultChanged"
});
await new Promise((resolve) => setTimeout(resolve, 100));
assert.strictEqual(outstandingPromises.length, 0);
assert.strictEqual(outstandingPromises.size, 0);
await manager.stop();
});
@ -171,7 +169,7 @@ describe("WebSocketManager", () => {
await new Promise((resolve) => setTimeout(resolve, 50));
const { outstandingPromises } = manager as unknown as {
outstandingPromises: Promise<unknown>[];
outstandingPromises: Set<Promise<void>>;
};
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
.webSocket;
@ -182,7 +180,7 @@ describe("WebSocketManager", () => {
});
await new Promise((resolve) => setTimeout(resolve, 100));
assert.strictEqual(outstandingPromises.length, 0);
assert.strictEqual(outstandingPromises.size, 0);
await manager.stop();
});
@ -203,19 +201,22 @@ describe("WebSocketManager", () => {
throw new Error("Buffer full");
};
assert.throws(() => {
manager.sendHandshakeMessage({
type: "handshake",
token: "test",
deviceId: "test",
lastSeenVaultUpdateId: null
});
});
assert.doesNotThrow(() => mockWs.onopen?.(new Event("open")));
assert.equal(manager.isWebSocketConnected, false);
assert(
(
mockLogger.warn as typeof mockLogger.warn & {
calls: [string][];
}
).calls.some(([message]) =>
message.includes("WebSocket handshake failed")
)
);
await manager.stop();
});
it("completes stop with timeout protection", async () => {
it("completes stop after retiring the socket", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
@ -227,7 +228,7 @@ describe("WebSocketManager", () => {
await new Promise((resolve) => setTimeout(resolve, 50));
await manager.stop();
assert.ok(true);
assert.equal(manager.isWebSocketConnected, false);
});
it("uses a secure URL, preserves the configured prefix, and encodes the vault", async () => {
@ -316,24 +317,351 @@ describe("WebSocketManager", () => {
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
.webSocket;
mockWs.simulateMessage({
type: "vaultEvents",
headEventId: 0,
events: []
type: "vaultChanged"
});
await new Promise((resolve) => setTimeout(resolve, 10));
const { outstandingPromises } = manager as unknown as {
outstandingPromises: Promise<unknown>[];
outstandingPromises: Set<Promise<void>>;
};
assert.ok(outstandingPromises.length > 0);
assert.ok(outstandingPromises.size > 0);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
resolveListener!();
await new Promise((resolve) => setTimeout(resolve, 50));
assert.strictEqual(outstandingPromises.length, 0);
assert.strictEqual(outstandingPromises.size, 0);
await manager.stop();
});
});
it(
"refused WebSocket connections and replacing a connecting socket do not emit unhandled errors",
{ timeout: 5_000 },
async () => {
const server = createServer();
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve)
);
const address = server.address();
assert(address !== null && typeof address !== "string");
await new Promise<void>((resolve, reject) =>
server.close((error) => {
if (error !== undefined) {
reject(error);
} else {
resolve();
}
})
);
const logger = new Logger();
const settings = new Settings(
logger,
{
remoteUri: `http://127.0.0.1:${address.port}`,
webSocketRetryIntervalMs: 10
},
async () => undefined
);
const manager = new WebSocketManager(
"test",
logger,
settings,
WebSocket
);
let closes = 0;
const disconnected = Promise.withResolvers<undefined>();
manager.onWebSocketStatusChanged.add((connected) => {
if (!connected && ++closes >= 2) {
disconnected.resolve(undefined);
}
});
try {
manager.start();
manager.start();
await disconnected.promise;
assert.equal(manager.isWebSocketConnected, false);
} finally {
await manager.stop();
}
}
);
for (const failedAttempt of [1, 2]) {
it(`recovers from a WebSocket constructor failure on attempt ${failedAttempt}`, async (context) => {
context.mock.timers.enable({ apis: ["setTimeout"] });
let attempts = 0;
class FailingSocket extends MockWebSocket {
public static OPEN = 1;
public constructor(url: string) {
if (++attempts === failedAttempt) {
throw new Error("socket allocation failed");
}
super(url);
}
}
const logger = new Logger();
const settings = new Settings(
logger,
{ remoteUri: "http://test", webSocketRetryIntervalMs: 1000 },
async () => undefined
);
const manager = new WebSocketManager(
"test",
logger,
settings,
FailingSocket as unknown as typeof WebSocket
);
try {
assert.doesNotThrow(() => {
manager.start();
});
context.mock.timers.tick(0);
if (failedAttempt === 2) {
const socket = (
manager as unknown as { webSocket: MockWebSocket }
).webSocket;
socket.close();
}
assert.doesNotThrow(() => {
context.mock.timers.tick(1000);
});
context.mock.timers.tick(1000);
assert.equal(attempts, failedAttempt + 1);
assert.equal(manager.isWebSocketConnected, true);
} finally {
await manager.stop();
}
});
}
for (const closeBehavior of ["throws", "stays open"]) {
it(`stop retires a socket even when close ${closeBehavior}`, async (context) => {
context.mock.timers.enable({ apis: ["setTimeout"] });
class BrokenCloseSocket extends MockWebSocket {
public static OPEN = 1;
public close(): void {
if (closeBehavior === "throws") {
throw new Error("close failed");
}
}
}
const logger = new Logger();
const settings = new Settings(
logger,
{ remoteUri: "http://test" },
async () => undefined
);
const manager = new WebSocketManager(
"test",
logger,
settings,
BrokenCloseSocket as unknown as typeof WebSocket
);
let connected = false,
delivered = 0;
manager.onWebSocketStatusChanged.add((status) => {
connected = status;
});
manager.onRemoteVaultUpdateReceived.add(async () => {
delivered++;
});
manager.start();
context.mock.timers.tick(0);
const socket = (manager as unknown as { webSocket: MockWebSocket })
.webSocket;
const delayedMessage = socket.onmessage;
await manager.stop(); // No close event or timer advance is needed.
assert.equal(manager.isWebSocketConnected, false);
assert.equal(connected, false);
socket.simulateMessage({
type: "vaultChanged"
});
delayedMessage?.(
new MockMessageEvent("message", {
data: JSON.stringify({ type: "vaultChanged" })
})
);
await manager.waitUntilFinished();
assert.equal(delivered, 0);
});
}
it("stop retires the socket immediately but drains messages already being handled", async (context) => {
context.mock.timers.enable({ apis: ["setTimeout"] });
const logger = new Logger();
const manager = new WebSocketManager(
"device",
logger,
new Settings(
logger,
{ remoteUri: "http://test", token: "test-token" },
async () => undefined
),
MockWebSocket as unknown as typeof WebSocket
);
const release = Promise.withResolvers<undefined>();
context.after(async () => {
release.resolve(undefined);
await manager.stop();
});
manager.start();
context.mock.timers.tick(0);
const socket = (manager as unknown as { webSocket: MockWebSocket })
.webSocket;
assert.deepEqual(JSON.parse(socket.sentMessages[0] ?? ""), {
type: "handshake",
token: "test-token",
deviceId: "device"
});
manager.onRemoteVaultUpdateReceived.add(async () => release.promise);
socket.simulateMessage({ type: "vaultChanged" });
let finished = false;
const stopping = manager.stop().then(() => {
finished = true;
});
assert.equal(manager.isWebSocketConnected, false);
assert.equal(socket.onmessage, null);
await Promise.resolve();
assert(!finished);
release.resolve(undefined);
await stopping;
assert(finished);
});
it("a handshake send failure retires the connection without escaping the open callback", async (context) => {
context.mock.timers.enable({ apis: ["setTimeout"] });
class BrokenSendSocket extends MockWebSocket {
public static OPEN = 1;
public send(): void {
throw new Error("transport closed during open");
}
}
const logger = new Logger();
const settings = new Settings(
logger,
{ remoteUri: "http://test" },
async () => undefined
);
const manager = new WebSocketManager(
"test",
logger,
settings,
BrokenSendSocket as unknown as typeof WebSocket
);
const statuses: boolean[] = [];
manager.onWebSocketStatusChanged.add((status) => statuses.push(status));
try {
manager.start();
assert.doesNotThrow(() => {
context.mock.timers.tick(0);
});
assert.equal(manager.isWebSocketConnected, false);
assert.equal(statuses.at(-1), false);
assert(
!statuses.includes(true),
"a failed handshake must never announce a connection"
);
} finally {
await manager.stop();
}
});
it("reconnects a connection that stops delivering messages", async (context) => {
context.mock.timers.enable({ apis: ["setTimeout"] });
let attempts = 0;
class CountingSocket extends MockWebSocket {
public static OPEN = 1;
public constructor(url: string) {
super(url);
attempts++;
}
}
const logger = new Logger();
const settings = new Settings(
logger,
{ remoteUri: "http://test", webSocketRetryIntervalMs: 1000 },
async () => undefined
);
const manager = new WebSocketManager(
"test",
logger,
settings,
CountingSocket as unknown as typeof WebSocket
);
const statuses: boolean[] = [];
manager.onWebSocketStatusChanged.add((status) => statuses.push(status));
try {
manager.start();
context.mock.timers.tick(0);
assert.equal(manager.isWebSocketConnected, true);
assert.equal(attempts, 1);
const silentSocket = (
manager as unknown as { webSocket: MockWebSocket }
).webSocket;
context.mock.timers.tick(WEBSOCKET_RECEIVE_TIMEOUT_MS);
assert.equal(manager.isWebSocketConnected, false);
assert.equal(statuses.at(-1), false);
assert.equal(silentSocket.onmessage, null);
context.mock.timers.tick(1000);
context.mock.timers.tick(0);
assert.equal(attempts, 2);
assert.equal(manager.isWebSocketConnected, true);
} finally {
await manager.stop();
}
});
it("does not reconnect while messages keep arriving before the deadline", async (context) => {
context.mock.timers.enable({ apis: ["setTimeout"] });
let attempts = 0;
class CountingSocket extends MockWebSocket {
public static OPEN = 1;
public constructor(url: string) {
super(url);
attempts++;
}
}
const logger = new Logger();
const settings = new Settings(
logger,
{ remoteUri: "http://test", webSocketRetryIntervalMs: 1000 },
async () => undefined
);
const manager = new WebSocketManager(
"test",
logger,
settings,
CountingSocket as unknown as typeof WebSocket
);
let disconnected = 0;
manager.onWebSocketStatusChanged.add((status) => {
if (!status) {
disconnected++;
}
});
try {
manager.start();
context.mock.timers.tick(0);
const socket = (manager as unknown as { webSocket: MockWebSocket })
.webSocket;
assert.equal(attempts, 1);
context.mock.timers.tick(10_000);
socket.simulateMessage({ type: "vaultChanged" });
context.mock.timers.tick(10_000);
socket.simulateMessage({ type: "cursorPositions", clients: [] });
context.mock.timers.tick(WEBSOCKET_RECEIVE_TIMEOUT_MS - 20_000);
assert.equal(manager.isWebSocketConnected, true);
assert.equal(disconnected, 0);
assert.equal(attempts, 1);
} finally {
await manager.stop();
}
});

View file

@ -0,0 +1,41 @@
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion -- No filesystem operation is reached before initialization. */
import assert from "node:assert/strict";
import { test } from "node:test";
import { SyncClient } from "./sync-client";
import type { FileSystemOperations } from "./file-operations/filesystem-operations";
import { awaitAll } from "./utils/await-all";
test("destroy during startup metadata work cannot restart the client", async () => {
const saving = Promise.withResolvers<undefined>();
const release = Promise.withResolvers<undefined>();
const client = await SyncClient.create({
fs: {} as FileSystemOperations,
persistence: {
load: async () => ({
settings: {
isSyncEnabled: true,
enableTelemetry: false,
remoteUri: "http://test"
}
}),
save: async () => {
saving.resolve(undefined);
await release.promise;
}
},
fetch: async () => {
assert.fail("a destroyed client must not start network requests");
}
});
const notification = client.syncLocallyCreatedFile("note.md");
await saving.promise;
const starting = client.start();
// Let start acquire the lifecycle lock and begin draining notification saves.
await Promise.resolve();
const destroying = client.destroy();
release.resolve(undefined);
await awaitAll([notification, starting, destroying]);
await assert.rejects(client.start(), /destroyed/u);
});

View file

@ -1,26 +1,28 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { mergeContent, fromStoredSnapshot, toStoredSnapshot } from "./content";
import { hashSnapshot, type HashedSnapshot } from "../snapshot";
import { mergeContent } from "./content";
const extensions = ["md"];
const snapshot = async (text: string) =>
toStoredSnapshot({ content: new TextEncoder().encode(text) });
const createSnapshot = async (text: string): Promise<HashedSnapshot> =>
hashSnapshot({ content: new TextEncoder().encode(text) });
describe("content reconciliation without file timestamps", () => {
it("round-trips content and editor selections without timestamps", async () => {
it("hashes snapshots without changing bytes or editor selections", async () => {
const original = {
content: new Uint8Array([0, 255, 13, 10]),
cursors: []
};
assert.deepEqual(
fromStoredSnapshot(await toStoredSnapshot(original)),
original
);
const snapshot = await hashSnapshot(original);
assert.deepEqual(snapshot.content, original.content);
assert.deepEqual(snapshot.cursors, original.cursors);
original.content.fill(1);
assert.deepEqual(snapshot.content, new Uint8Array([0, 255, 13, 10]));
});
it("keeps local binary edits when the server content is unchanged", async () => {
const base = await snapshot("\0base");
const local = await snapshot("\0local");
const base = await createSnapshot("\0base");
const local = await createSnapshot("\0local");
const merged = await mergeContent(
"file.bin",
base,
@ -28,12 +30,12 @@ describe("content reconciliation without file timestamps", () => {
base,
extensions
);
assert.equal(merged.contentBase64, local.contentBase64);
assert.deepEqual(merged.content, local.content);
});
it("incorporates server binary edits when local content is unchanged", async () => {
const base = await snapshot("\0base");
const remote = await snapshot("\0remote");
const base = await createSnapshot("\0base");
const remote = await createSnapshot("\0remote");
const merged = await mergeContent(
"file.bin",
base,
@ -41,7 +43,7 @@ describe("content reconciliation without file timestamps", () => {
remote,
extensions
);
assert.equal(merged.contentBase64, remote.contentBase64);
assert.deepEqual(merged.content, remote.content);
});
it("keeps the server on concurrent binary or unmergeable text edits", async () => {
@ -49,10 +51,10 @@ describe("content reconciliation without file timestamps", () => {
["file.bin", "\0"],
["file.md", "\0"],
["file.json", ""]
]) {
const base = await snapshot(`${prefix}base`);
const local = await snapshot(`${prefix}local`);
const remote = await snapshot(`${prefix}remote`);
] as const) {
const base = await createSnapshot(`${prefix}base`);
const local = await createSnapshot(`${prefix}local`);
const remote = await createSnapshot(`${prefix}remote`);
const merged = await mergeContent(
path,
base,
@ -60,13 +62,13 @@ describe("content reconciliation without file timestamps", () => {
remote,
extensions
);
assert.equal(merged.contentBase64, remote.contentBase64, path);
assert.deepEqual(merged.content, remote.content, path);
}
});
it("keeps the server for differing binary content without a common base", async () => {
const local = await snapshot("\0local");
const remote = await snapshot("\0remote");
const local = await createSnapshot("\0local");
const remote = await createSnapshot("\0remote");
const merged = await mergeContent(
"file.bin",
undefined,
@ -74,19 +76,23 @@ describe("content reconciliation without file timestamps", () => {
remote,
extensions
);
assert.equal(merged.contentBase64, remote.contentBase64);
assert.deepEqual(merged.content, remote.content);
});
it("still merges independent text edits from both sides", async () => {
const merged = await mergeContent(
"note.md",
await snapshot("First paragraph.\n\nLast paragraph.\n"),
await snapshot("Updated first paragraph.\n\nLast paragraph.\n"),
await snapshot("First paragraph.\n\nUpdated last paragraph.\n"),
await createSnapshot("First paragraph.\n\nLast paragraph.\n"),
await createSnapshot(
"Updated first paragraph.\n\nLast paragraph.\n"
),
await createSnapshot(
"First paragraph.\n\nUpdated last paragraph.\n"
),
extensions
);
assert.equal(
new TextDecoder().decode(fromStoredSnapshot(merged).content),
new TextDecoder().decode(merged.content),
"Updated first paragraph.\n\nUpdated last paragraph.\n"
);
});

View file

@ -1,67 +1,304 @@
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
import assert from "node:assert/strict";
import { it } from "node:test";
import type { Database } from "../persistence/database";
import type { FileOperations } from "../file-operations/file-operations";
import {
Database,
createEmptyDatabase,
type RelativePath,
type VaultUpdateId
} from "../persistence/database";
import type { FileSystemOperations } from "../file-operations/filesystem-operations";
import type { WebSocketManager } from "../services/websocket-manager";
import type { ClientCursors } from "../services/types/ClientCursors";
import { DocumentUpToDateness } from "../types/document-up-to-dateness";
import { EventListeners } from "../utils/data-structures/event-listeners";
import { hash } from "../utils/hash";
import { CursorTracker } from "./cursor-tracker";
import { FileChangeNotifier } from "./file-change-notifier";
it("retains future cursors and publishes them when content catches up", async () => {
function createDatabase(digest: string): Database {
const state = createEmptyDatabase("test");
state.local.document = "note.md";
state.documents.document = { base: { vaultUpdateId: 1, hash: digest } };
return new Database(
state,
async () => undefined,
"test",
async () => undefined
);
}
it("keeps the last usable cursor while retaining only the latest future update", async (context) => {
const content = new TextEncoder().encode("current");
const contentHash = await hash(content);
const database = {
getDocumentByDocumentId: () => ({
documentId: "document",
relativePath: "note.md",
isDeleted: false,
metadata: { parentVersionId: 2, hash: contentHash }
})
} as unknown as Database;
const database = createDatabase(contentHash);
const websocket = {
onWebSocketStatusChanged: new EventListeners(),
onRemoteCursorsUpdateReceived: new EventListeners<
(cursors: ClientCursors[]) => Promise<void>
>(),
updateLocalCursors: () => {}
updateLocalCursors: () => undefined
} as unknown as WebSocketManager;
const files = { read: async () => content } as unknown as FileOperations;
const notifier = new FileChangeNotifier();
const tracker = new CursorTracker(database, websocket, files, notifier);
const future = {
const notifier = new EventListeners<(path: RelativePath) => unknown>();
const tracker = new CursorTracker(
database,
websocket,
{ read: async () => ({ content }) } as unknown as FileSystemOperations,
notifier
);
context.after(() => {
tracker.reset();
});
let received: ClientCursors[] = [];
tracker.onRemoteCursorsUpdated.add((cursors) => {
received = cursors;
});
const cursor = (version: number): ClientCursors => ({
userName: "Other",
deviceId: "device",
documentsWithCursors: [
{
document_id: "document",
relative_path: "old-name.md",
vault_update_id: 2,
cursors: [{ start: 0, end: 1 }]
vault_update_id: version,
cursors: [{ start: version, end: version }]
}
],
upToDateness: DocumentUpToDateness.Later
};
const internals = tracker as unknown as {
knownRemoteCursors: (typeof future)[];
getRelevantAndPruneKnownClientCursors: () => ClientCursors[];
};
internals.knownRemoteCursors = [future];
assert.deepEqual(internals.getRelevantAndPruneKnownClientCursors(), []);
assert.strictEqual(internals.knownRemoteCursors.length, 1);
let received: ClientCursors[] = [];
tracker.onRemoteCursorsUpdated.add((cursors) => {
received = cursors;
]
});
await notifier.onFileChanged.triggerAsync("note.md");
for (const remoteVersion of [1, 2, 3]) {
await websocket.onRemoteCursorsUpdateReceived.triggerAsync([
cursor(remoteVersion)
]);
assert.equal(received[0]?.documentsWithCursors[0]?.vault_update_id, 1);
}
assert.strictEqual(received.length, 1);
assert.strictEqual(
received[0].documentsWithCursors[0].relative_path,
"note.md"
assert.ok(database.state.documents.document);
database.state.documents.document.base = {
vaultUpdateId: 2,
hash: contentHash
};
await notifier.triggerAsync("note.md");
assert.equal(
received[0]?.documentsWithCursors[0]?.vault_update_id,
1,
"superseded future cursors must not reappear"
);
database.state.documents.document.base = {
vaultUpdateId: 3,
hash: contentHash
};
await notifier.triggerAsync("note.md");
assert.equal(received[0].documentsWithCursors[0].vault_update_id, 3);
assert.equal(received[0].documentsWithCursors[0].relative_path, "note.md");
await websocket.onRemoteCursorsUpdateReceived.triggerAsync([]);
assert.deepEqual(received, []);
});
it(
"refreshes unchanged cursors on reconnect and heartbeat, and follows the document through renames",
{ timeout: 5_000 },
async (context) => {
context.mock.timers.enable({ apis: ["setInterval"] });
const content = new TextEncoder().encode("hello");
let path = "note.md";
const database = createDatabase(await hash(content));
const sent: {
documentsWithCursors: {
relative_path: RelativePath;
vault_update_id: VaultUpdateId | null;
}[];
}[] = [];
let published = Promise.withResolvers<undefined>();
const websocket = {
onWebSocketStatusChanged: new EventListeners<
(connected: boolean) => unknown
>(),
onRemoteCursorsUpdateReceived: new EventListeners(),
updateLocalCursors: (value: (typeof sent)[number]) => {
sent.push(value);
published.resolve(undefined);
}
} as unknown as WebSocketManager;
const notifier = new EventListeners<(path: RelativePath) => unknown>();
const read = context.mock.fn(async () => ({ content }));
const tracker = new CursorTracker(
database,
websocket,
{ read } as unknown as FileSystemOperations,
notifier
);
const waitForSend = async (trigger: () => void): Promise<void> => {
published = Promise.withResolvers<undefined>();
trigger();
// Hashing finishes outside the event loop; a fixed number of turns
// cannot guarantee that the cursor snapshot has been published.
await published.promise;
};
try {
await tracker.sendLocalCursorsToServer({
"note.md": [{ start: 1, end: 2 }]
});
assert.equal(sent.length, 1);
await tracker.sendLocalCursorsToServer({
"note.md": [{ start: 1, end: 2 }]
});
assert.equal(sent.length, 1);
await waitForSend(() => {
websocket.onWebSocketStatusChanged.trigger(true);
});
assert.equal(sent.length, 2);
await waitForSend(() => {
context.mock.timers.tick(15_000);
});
assert.equal(sent.length, 3);
path = "renamed.md";
database.state.local.document = path;
await notifier.triggerAsync(path);
assert.equal(
sent.at(-1)?.documentsWithCursors[0]?.relative_path,
path
);
websocket.onWebSocketStatusChanged.trigger(false);
const count = Number(sent.length);
const readCount = read.mock.callCount();
context.mock.timers.tick(30_000);
// A surviving heartbeat would start a snapshot read in the send lock's
// next microtask, even if hashing that snapshot has not finished yet.
await Promise.resolve();
assert.equal(read.mock.callCount(), readCount);
assert.equal(sent.length, count);
await waitForSend(() => {
websocket.onWebSocketStatusChanged.trigger(true);
});
assert.equal(sent.length, count + 1);
} finally {
tracker.reset();
context.mock.timers.reset();
}
}
);
for (const interruption of ["disconnect", "reset"] as const) {
it(`an in-flight cursor read cannot resurrect presence after ${interruption}`, async () => {
const content = new TextEncoder().encode("current");
const contentHash = await hash(content);
const entered = Promise.withResolvers<undefined>();
const release = Promise.withResolvers<undefined>();
const database = createDatabase(contentHash);
const websocket = {
onWebSocketStatusChanged: new EventListeners<
(connected: boolean) => unknown
>(),
onRemoteCursorsUpdateReceived: new EventListeners<
(cursors: ClientCursors[]) => Promise<void>
>(),
updateLocalCursors: () => undefined
} as unknown as WebSocketManager;
const tracker = new CursorTracker(
database,
websocket,
{
read: async () => {
entered.resolve(undefined);
await release.promise;
return { content };
}
} as unknown as FileSystemOperations,
new EventListeners<(path: RelativePath) => unknown>()
);
let received: ClientCursors[] = [];
tracker.onRemoteCursorsUpdated.add((cursors) => {
received = cursors;
});
const cursor: ClientCursors = {
userName: "Other",
deviceId: "device",
documentsWithCursors: [
{
document_id: "document",
relative_path: "note.md",
vault_update_id: 1,
cursors: [{ start: 0, end: 1 }]
}
]
};
const first = websocket.onRemoteCursorsUpdateReceived.triggerAsync([
cursor
]);
await entered.promise;
const queued = websocket.onRemoteCursorsUpdateReceived.triggerAsync([
cursor
]);
if (interruption === "reset") {
tracker.reset();
} else {
websocket.onWebSocketStatusChanged.trigger(false);
}
assert.deepEqual(received, []);
release.resolve(undefined);
await first;
await queued;
assert.deepEqual(
received,
[],
"a retired connection must not restore its cursors"
);
tracker.reset();
});
}
it("a remote cursor becomes outdated if the document advances during its snapshot read", async () => {
const content = new TextEncoder().encode("old");
const database = createDatabase(await hash(content));
const entered = Promise.withResolvers<undefined>(),
release = Promise.withResolvers<undefined>();
const websocket = {
onWebSocketStatusChanged: new EventListeners<
(connected: boolean) => unknown
>(),
onRemoteCursorsUpdateReceived: new EventListeners<
(cursors: ClientCursors[]) => Promise<void>
>(),
updateLocalCursors: () => undefined
} as unknown as WebSocketManager;
const tracker = new CursorTracker(
database,
websocket,
{
read: async () => {
entered.resolve(undefined);
await release.promise;
return { content };
}
} as unknown as FileSystemOperations,
new EventListeners<(path: RelativePath) => unknown>()
);
let outdated: boolean | undefined = undefined;
tracker.onRemoteCursorsUpdated.add((cursors) => {
outdated = cursors[0]?.isOutdated;
});
const update = websocket.onRemoteCursorsUpdateReceived.triggerAsync([
{
userName: "Other",
deviceId: "device",
documentsWithCursors: [
{
document_id: "document",
relative_path: "note.md",
vault_update_id: 1,
cursors: [{ start: 0, end: 1 }]
}
]
}
]);
await entered.promise;
assert.ok(database.state.documents.document);
database.state.documents.document.base = {
vaultUpdateId: 2,
hash: await hash(new TextEncoder().encode("new content"))
};
release.resolve(undefined);
await update;
assert.equal(outdated, true);
tracker.reset();
});

View file

@ -0,0 +1,71 @@
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion -- Test doubles implement only the exercised adapter methods. */
import assert from "node:assert/strict";
import { test } from "node:test";
import { CursorTracker } from "./cursor-tracker";
import { EventListeners } from "../utils/data-structures/event-listeners";
import { hash } from "../utils/hash";
import {
Database,
createEmptyDatabase,
type RelativePath
} from "../persistence/database";
import type { FileSystemOperations } from "../file-operations/filesystem-operations";
import type { WebSocketManager } from "../services/websocket-manager";
import type { DocumentWithCursors } from "../services/types/DocumentWithCursors";
for (const change of ["version", "identity", "path", "delete"] as const) {
test(`outgoing cursor is dirty when ${change} changes during its snapshot read`, async (): Promise<void> => {
// Equal bytes deliberately defeat a freshness check that compares only hashes.
const content = new TextEncoder().encode("same bytes");
const digest = await hash(content);
const state = createEmptyDatabase("test");
state.local.a = "a.md";
state.documents.a = { base: { vaultUpdateId: 1, hash: digest } };
const database = new Database(
state,
async () => undefined,
"test",
async () => undefined
);
const sent: { documentsWithCursors: DocumentWithCursors[] }[] = [];
const websocket = {
onWebSocketStatusChanged: new EventListeners(),
onRemoteCursorsUpdateReceived: new EventListeners(),
updateLocalCursors: (data: {
documentsWithCursors: DocumentWithCursors[];
}): number => sent.push(data)
};
const files = {
read: async (): Promise<{ content: Uint8Array }> => {
const current = database.state;
assert.ok(current.documents.a?.base);
assert.ok(current.local.a !== undefined);
if (change === "delete") {
delete current.local.a;
} else if (change === "version") {
current.documents.a.base.vaultUpdateId = 2;
} else if (change === "identity") {
current.local.replacement = current.local.a;
current.documents.replacement = current.documents.a;
delete current.local.a;
} else {
current.local.a = "moved.md";
}
return { content };
}
};
const tracker = new CursorTracker(
database,
websocket as unknown as WebSocketManager,
files as unknown as FileSystemOperations,
new EventListeners<(path: RelativePath) => unknown>()
);
await tracker.sendLocalCursorsToServer({
"a.md": [{ start: 4, end: 4 }]
});
assert.equal(sent.length, 1);
assert.equal(sent[0]?.documentsWithCursors[0]?.vault_update_id, null);
tracker.reset();
});
}

View file

@ -0,0 +1,138 @@
import assert from "node:assert/strict";
import { afterEach, beforeEach, describe, it, mock } from "node:test";
import { Settings, type SyncSettings } from "../persistence/settings";
import { Logger } from "../tracing/logger";
import { PermanentSyncError } from "../errors/errors";
import { SyncPhase, SyncScheduler } from "./sync-scheduler";
function createScheduler(
overrides: Partial<SyncSettings> = {},
pass: () => Promise<void> = async () => undefined
): { scheduler: SyncScheduler; runCount: () => number } {
let runs = 0;
const scheduler = new SyncScheduler(
new Settings(new Logger(), overrides, async () => undefined),
async () => {
do {
scheduler.beginPass();
runs++;
await pass();
} while (scheduler.needsAnotherPass);
},
() => undefined,
() => false
);
return { scheduler, runCount: () => runs };
}
describe("Sync scheduler transitions", () => {
beforeEach(() => {
mock.timers.enable({ apis: ["setTimeout"] });
});
afterEach(() => {
mock.timers.reset();
});
for (const interval of [undefined, 0]) {
it(`leaves polling disabled for interval ${interval}`, async () => {
const { scheduler, runCount } = createScheduler({
syncIntervalMs: interval
});
scheduler.start();
await scheduler.waitUntilFinished();
assert.equal(scheduler.currentPhase, SyncPhase.Idle);
mock.timers.tick(60_000);
assert.equal(runCount(), 1);
await scheduler.stop();
assert.equal(scheduler.currentPhase, SyncPhase.Stopped);
});
}
it("polls after the configured interval and cancels the timer on stop", async () => {
const { scheduler, runCount } = createScheduler({ syncIntervalMs: 25 });
scheduler.start();
await scheduler.waitUntilFinished();
mock.timers.tick(24);
assert.equal(runCount(), 1);
mock.timers.tick(1);
assert.equal(runCount(), 2);
await scheduler.waitUntilFinished();
await scheduler.stop();
mock.timers.tick(25);
assert.equal(runCount(), 2);
});
it("coalesces notifications during a pass without running concurrently", async () => {
const gate = Promise.withResolvers<undefined>();
const { scheduler, runCount } = createScheduler(
{},
async () => gate.promise
);
scheduler.start();
assert.equal(scheduler.currentPhase, SyncPhase.Running);
scheduler.requestSync();
scheduler.requestSync();
assert.equal(scheduler.currentPhase, SyncPhase.RunRequested);
assert.equal(runCount(), 1);
gate.resolve(undefined);
await scheduler.waitUntilFinished();
assert.equal(runCount(), 2);
assert.equal(scheduler.currentPhase, SyncPhase.Idle);
await scheduler.stop();
});
it("drains an active pass on stop and discards queued notifications", async () => {
const gate = Promise.withResolvers<undefined>();
const { scheduler, runCount } = createScheduler(
{},
async () => gate.promise
);
scheduler.start();
scheduler.requestSync();
const stopped = scheduler.stop();
assert.equal(scheduler.currentPhase, SyncPhase.Stopping);
scheduler.requestSync();
gate.resolve(undefined);
await stopped;
assert.equal(scheduler.currentPhase, SyncPhase.Stopped);
assert.equal(runCount(), 1);
});
it("retries transient failures and returns to idle on success", async () => {
let attempts = 0;
const { scheduler } = createScheduler(
{ networkRetryIntervalMs: 10 },
async () => {
if (++attempts === 1) {
throw new Error("offline");
}
}
);
scheduler.start();
await assert.rejects(scheduler.waitUntilFinished(), /offline/u);
assert.equal(scheduler.currentPhase, SyncPhase.WaitingToRetry);
mock.timers.tick(10);
await scheduler.waitUntilFinished();
assert.equal(scheduler.currentPhase, SyncPhase.Idle);
assert.equal(attempts, 2);
await scheduler.stop();
});
it("blocks automatic retries of permanent errors but accepts an explicit request", async () => {
const { scheduler, runCount } = createScheduler(
{ networkRetryIntervalMs: 10 },
async () => {
throw new PermanentSyncError("rejected");
}
);
scheduler.start();
await assert.rejects(scheduler.waitUntilFinished(), /rejected/u);
assert.equal(scheduler.currentPhase, SyncPhase.Blocked);
mock.timers.tick(100);
assert.equal(runCount(), 1);
scheduler.requestSync();
await assert.rejects(scheduler.waitUntilFinished(), /rejected/u);
assert.equal(runCount(), 2);
await scheduler.stop();
});
});

View file

@ -0,0 +1,177 @@
import { ContentSync, selectContentHead } from "./content-sync";
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
import assert from "node:assert";
import { describe, it } from "node:test";
import type { Database, RelativePath } from "../persistence/database";
import { Settings } from "../persistence/settings";
import type { FileOperations } from "../file-operations/file-operations";
import type { SyncService } from "../services/sync-service";
import type { ServerConfig } from "../services/server-config";
import type { WebSocketManager } from "../services/websocket-manager";
import { Logger } from "../tracing/logger";
import type { SyncHistory } from "../tracing/sync-history";
import { EventListeners } from "../utils/data-structures/event-listeners";
import { Syncer } from "./syncer";
import { FixedSizeDocumentCache } from "../utils/data-structures/fix-sized-cache";
import { hashSnapshot } from "../snapshot";
import { undiff } from "reconcile-text";
describe("Syncer per-file state and event delivery", () => {
const head = {
vaultUpdateId: 1,
documentId: "document",
updatedDate: "2026-01-01T00:00:00Z",
userId: "user",
deviceId: "device",
contentSize: 4
};
function fixture(): {
database: Database;
websocket: WebSocketManager;
syncer: Syncer;
} {
const database = {
state: {
local: { document: "note.md" },
documents: {
document: {
observedHash: "hash",
base: { ...head, hash: "hash" }
}
},
fileManifest: {
fileManifestId: 1,
entries: { document: "note.md" }
},
lastSeenUpdateId: 0
},
findDocumentId: (path: RelativePath) =>
path === "note.md" ? "document" : undefined
} as unknown as Database;
const websocket = {
onWebSocketStatusChanged: new EventListeners<
(connected: boolean) => unknown
>(),
onRemoteVaultUpdateReceived: new EventListeners<
() => Promise<void>
>()
} as unknown as WebSocketManager;
const service = {
events: async () => {
return { headEventId: 0, endEventId: 0, events: [] };
}
} as unknown as SyncService;
const syncer = new Syncer(
{} as Logger,
database,
new Settings(
new Logger(),
{ syncIntervalMs: 0 },
async () => undefined
),
service,
websocket,
{} as FileOperations,
{} as ServerConfig,
{} as SyncHistory,
new FixedSizeDocumentCache(1024)
);
(syncer as unknown as { hasScanned: boolean }).hasScanned = true;
return {
database,
websocket,
syncer
};
}
it("reports only a genuinely current path as up to date", () => {
const { syncer } = fixture();
assert.strictEqual(syncer.isDocumentUpToDate("note.md"), true);
assert.strictEqual(syncer.isDocumentUpToDate("missing.md"), false);
const internals = syncer as unknown as {
unsyncablePaths: Set<RelativePath>;
};
internals.unsyncablePaths.add("note.md");
assert.strictEqual(syncer.isDocumentUpToDate("note.md"), false);
});
it("projects content responses to metadata before persistence", () => {
const projected = selectContentHead({
...head,
contentBase64: "secret"
} as typeof head);
assert.deepEqual(projected, {
vaultUpdateId: head.vaultUpdateId,
contentSize: head.contentSize
});
assert.strictEqual("contentBase64" in projected, false);
});
});
describe("Syncer diff uploads", () => {
const encoder = new TextEncoder();
function fixture(cacheSize = 1024): {
content: ContentSync;
parent: Uint8Array;
} {
const cache = new FixedSizeDocumentCache(cacheSize);
const parent = encoder.encode("one two three\n");
const content = new ContentSync(
{ state: {} } as Database,
new Settings(
new Logger(),
{ syncIntervalMs: 0 },
async () => undefined
),
{
getDocumentVersionContent: async () => parent
} as unknown as SyncService,
{} as FileOperations,
{
getConfig: async () => ({ mergeableFileExtensions: ["md"] })
} as ServerConfig,
cache
);
return { content, parent };
}
it("sends a diff when the fetched parent is still cached", async () => {
const { content, parent } = fixture();
await content.fetchSnapshot(
{ vaultUpdateId: 7, documentId: "document" },
"note.md"
);
const changed = "one changed three\n";
const payload = await content.createUploadPayload(
"note.md",
7,
await hashSnapshot({ content: encoder.encode(changed) })
);
assert.equal(payload.type, "Diff");
if (payload.type === "Diff") {
assert.equal(
undiff(new TextDecoder().decode(parent), payload.value),
changed
);
}
});
it("falls back to a snapshot when the parent cannot fit in the cache", async () => {
const { content } = fixture(0);
await content.fetchSnapshot(
{ vaultUpdateId: 7, documentId: "document" },
"note.md"
);
const payload = await content.createUploadPayload(
"note.md",
7,
await hashSnapshot({ content: encoder.encode("changed") })
);
assert.equal(payload.type, "Snapshot");
});
});

View file

@ -54,3 +54,22 @@ void test("awaitAll works with async functions", async () => {
assert.strictEqual(results[0], "async");
assert.strictEqual(results[1], 123);
});
void test("awaitAll drains unfinished work before reporting a rejection", async () => {
const pending = Promise.withResolvers<undefined>();
const error = new Error("failed first");
let finished = false;
const checked = assert
.rejects(awaitAll([Promise.reject(error), pending.promise]), error)
.then(() => {
finished = true;
});
try {
await new Promise((resolve) => setImmediate(resolve));
assert.equal(finished, false);
} finally {
pending.resolve(undefined);
}
await checked;
});

View file

@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { decodeText } from "./decode-text";
import { isBinary } from "./is-binary";
test("text decoding preserves BOMs and line endings across successive files", () => {
for (const text of ["\uFEFFfirst\r\n", "plain\n", "\uFEFFé🙂\r\n"]) {
const bytes = new TextEncoder().encode(text);
assert.equal(isBinary(bytes), false);
assert.equal(decodeText(bytes), text);
}
});
test("invalid UTF-8 is binary and does not affect subsequent text decoding", () => {
const invalid = new Uint8Array([0xc3]);
assert.equal(isBinary(invalid), true);
assert.throws(() => decodeText(invalid));
assert.equal(decodeText(new TextEncoder().encode("valid")), "valid");
assert.equal(isBinary(new Uint8Array([0])), true);
});

View file

@ -7,6 +7,7 @@ describe("globsToRegexes", () => {
it("basicExample", async () => {
const [regex] = globsToRegexes([".git/**"], new Logger());
assert.ok(regex);
assert.ok(regex.test(".git/objects/object"));
assert.ok(regex.test(".git/objects/.object"));
});

View file

@ -5,13 +5,19 @@ import { isFileTypeMergeable } from "./is-file-type-mergeable";
const mergeableExtensions = ["md", "txt"];
describe("isFileTypeMergeable", () => {
it("should return true for .md files", () => {
assert.strictEqual(isFileTypeMergeable(".md", mergeableExtensions), true);
assert.strictEqual(
isFileTypeMergeable(".md", mergeableExtensions),
true
);
assert.strictEqual(
isFileTypeMergeable("hi.md", mergeableExtensions),
true
);
assert.strictEqual(
isFileTypeMergeable("my/path/to/my/document.md", mergeableExtensions),
isFileTypeMergeable(
"my/path/to/my/document.md",
mergeableExtensions
),
true
);
});
@ -40,7 +46,10 @@ describe("isFileTypeMergeable", () => {
true
);
assert.strictEqual(
isFileTypeMergeable("my/path/to/my/DOCUMENT.MD", mergeableExtensions),
isFileTypeMergeable(
"my/path/to/my/DOCUMENT.MD",
mergeableExtensions
),
true
);
assert.strictEqual(

View file

@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { test } from "node:test";
import { allocatePortablePath, validatePortablePaths } from "./portable-path";
test("a small deeply nested manifest fits within a bounded validation heap", () => {
const source = pathToFileURL(resolve(__dirname, "portable-path.ts")).href;
const result = spawnSync(
process.execPath,
[
"--max-old-space-size=64",
"--import",
require.resolve("tsx"),
"--input-type=module",
"--eval",
`
import { validatePortablePaths } from ${JSON.stringify(source)};
const directory = Array(20000).fill("a").join("/");
validatePortablePaths([directory + "/one.md", directory + "/two.md"]);
`
],
{ encoding: "utf8", timeout: 10000 }
);
assert.equal(result.status, 0, result.stderr || String(result.error));
});
test("conflict allocation renames the first obstructed component deterministically", () => {
const cases: { wanted: string; occupied: string[]; expected: string }[] = [
{
wanted: "Notes/a.md",
occupied: ["Notes/b.md"],
expected: "Notes/a.md"
},
{
wanted: "Notes/a.md",
occupied: ["notes/b.md"],
expected: "Notes (conflict new)/a.md"
},
{
wanted: "Notes/a.md",
occupied: ["Notes"],
expected: "Notes (conflict new)/a.md"
},
{
wanted: "Notes",
occupied: ["Notes/a.md"],
expected: "Notes (conflict new)"
},
{
wanted: "note.md",
occupied: ["note.md", "note (conflict new).md"],
expected: "note (conflict new) (1).md"
},
{
wanted: "Straße/a.md",
occupied: ["STRASSE/b.md"],
expected: "Straße (conflict new)/a.md"
}
];
for (const { wanted, occupied, expected } of cases) {
const actual = allocatePortablePath(wanted, "new", occupied);
assert.equal(actual, expected);
assert.equal(
allocatePortablePath(wanted, "new", occupied.toReversed()),
actual
);
validatePortablePaths([...occupied, actual]);
}
});
test("shared directories remain distinct from aliases and file ancestors", () => {
validatePortablePaths(["Notes/one.md", "Notes/two.md", "Other/one.md"]);
for (const paths of [
["Notes/one.md", "notes/two.md"],
["Straße/one.md", "STRASSE/two.md"],
["Notes", "Notes/one.md"],
["Notes/one.md", "Notes"],
["Notes/one.md", "Notes/one.md"]
]) {
assert.throws(() => {
validatePortablePaths(paths);
}, /Conflicting path/);
}
});

View file

@ -37,7 +37,7 @@ describe("rateLimit", () => {
await promise2;
assert.strictEqual(mockFn.mock.callCount(), 2);
assert.deepStrictEqual(mockFn.mock.calls[1].arguments, [2]);
assert.deepStrictEqual(mockFn.mock.calls[1]?.arguments, [2]);
});
it("should use the most recent arguments if multiple calls are made within interval", async () => {
@ -58,7 +58,7 @@ describe("rateLimit", () => {
assert.strictEqual(await promise3, undefined);
assert.strictEqual(mockFn.mock.callCount(), 2);
assert.deepStrictEqual(mockFn.mock.calls[0].arguments, ["first"]);
assert.deepStrictEqual(mockFn.mock.calls[1].arguments, ["third"]);
assert.deepStrictEqual(mockFn.mock.calls[0]?.arguments, ["first"]);
assert.deepStrictEqual(mockFn.mock.calls[1]?.arguments, ["third"]);
});
});