diff --git a/packages/automerge-repo-network-broadcastchannel/test/index.test.ts b/packages/automerge-repo-network-broadcastchannel/test/index.test.ts index 5a533329c..19c9a812e 100644 --- a/packages/automerge-repo-network-broadcastchannel/test/index.test.ts +++ b/packages/automerge-repo-network-broadcastchannel/test/index.test.ts @@ -1,7 +1,7 @@ import { PeerId } from "@automerge/automerge-repo" import { describe, it } from "vitest" import { - runAdapterTests, + runNetworkAdapterTests, type SetupFn, } from "../../automerge-repo/src/helpers/tests/network-adapter-tests.js" import { BroadcastChannelNetworkAdapter } from "../src/index.js" @@ -15,7 +15,7 @@ describe("BroadcastChannel", () => { return { adapters: [a, b, c] } } - runAdapterTests(setup) + runNetworkAdapterTests(setup) it("allows a channel name to be specified in the options and limits messages to that channel", async () => { const a = new BroadcastChannelNetworkAdapter() diff --git a/packages/automerge-repo-network-messagechannel/test/index.test.ts b/packages/automerge-repo-network-messagechannel/test/index.test.ts index 3e104dae1..73571d58d 100644 --- a/packages/automerge-repo-network-messagechannel/test/index.test.ts +++ b/packages/automerge-repo-network-messagechannel/test/index.test.ts @@ -1,10 +1,10 @@ import { describe } from "vitest" -import { runAdapterTests } from "../../automerge-repo/src/helpers/tests/network-adapter-tests.js" +import { runNetworkAdapterTests } from "../../automerge-repo/src/helpers/tests/network-adapter-tests.js" import { MessageChannelNetworkAdapter as Adapter } from "../src/index.js" // bob is the hub, alice and charlie are spokes describe("MessageChannelNetworkAdapter", () => { - runAdapterTests(async () => { + runNetworkAdapterTests(async () => { const aliceBobChannel = new MessageChannel() const bobCharlieChannel = new MessageChannel() @@ -24,7 +24,7 @@ describe("MessageChannelNetworkAdapter", () => { }, "hub and spoke") // all 3 peers connected directly to each other - runAdapterTests(async () => { + runNetworkAdapterTests(async () => { const aliceBobChannel = new MessageChannel() const bobCharlieChannel = new MessageChannel() const aliceCharlieChannel = new MessageChannel() diff --git a/packages/automerge-repo-network-websocket/test/Websocket.test.ts b/packages/automerge-repo-network-websocket/test/Websocket.test.ts index a597bc67d..08b391f66 100644 --- a/packages/automerge-repo-network-websocket/test/Websocket.test.ts +++ b/packages/automerge-repo-network-websocket/test/Websocket.test.ts @@ -10,7 +10,7 @@ import { import { generateAutomergeUrl } from "@automerge/automerge-repo/dist/AutomergeUrl" import { eventPromise } from "@automerge/automerge-repo/src/helpers/eventPromise" import { headsAreSame } from "@automerge/automerge-repo/src/helpers/headsAreSame.js" -import { runAdapterTests } from "@automerge/automerge-repo/src/helpers/tests/network-adapter-tests.js" +import { runNetworkAdapterTests } from "@automerge/automerge-repo/src/helpers/tests/network-adapter-tests.js" import { DummyStorageAdapter } from "@automerge/automerge-repo/test/helpers/DummyStorageAdapter.js" import assert from "assert" import * as CBOR from "cbor-x" @@ -27,7 +27,7 @@ describe("Websocket adapters", () => { const serverPeerId = "server" as PeerId const documentId = parseAutomergeUrl(generateAutomergeUrl()).documentId - runAdapterTests(async () => { + runNetworkAdapterTests(async () => { const { clients: [aliceAdapter, bobAdapter], server, diff --git a/packages/automerge-repo-storage-nodefs/package.json b/packages/automerge-repo-storage-nodefs/package.json index e5358e139..8df7536e5 100644 --- a/packages/automerge-repo-storage-nodefs/package.json +++ b/packages/automerge-repo-storage-nodefs/package.json @@ -9,7 +9,8 @@ "main": "dist/index.js", "scripts": { "build": "tsc", - "watch": "npm-watch build" + "watch": "npm-watch build", + "test": "vitest" }, "dependencies": { "@automerge/automerge-repo": "workspace:*", diff --git a/packages/automerge-repo-storage-nodefs/src/index.ts b/packages/automerge-repo-storage-nodefs/src/index.ts index 4a9079a24..3c0a8f56a 100644 --- a/packages/automerge-repo-storage-nodefs/src/index.ts +++ b/packages/automerge-repo-storage-nodefs/src/index.ts @@ -76,9 +76,10 @@ export class NodeFSStorageAdapter implements StorageAdapterInterface { // The "keys" in the cache don't include the baseDirectory. // We want to de-dupe with the cached keys so we'll use getKey to normalize them. - const diskKeys: string[] = diskFiles.map((fileName: string) => - getKey([path.relative(this.baseDirectory, fileName)]) - ) + const diskKeys: string[] = diskFiles.map((fileName: string) => { + const k = getKey([path.relative(this.baseDirectory, fileName)]) + return k.slice(0, 2) + k.slice(3) + }) // Combine and deduplicate the lists of keys const allKeys = [...new Set([...cachedKeys, ...diskKeys])] @@ -113,13 +114,12 @@ export class NodeFSStorageAdapter implements StorageAdapterInterface { private getFilePath(keyArray: string[]): string { const [firstKey, ...remainingKeys] = keyArray - const firstKeyDir = path.join( + return path.join( this.baseDirectory, firstKey.slice(0, 2), - firstKey.slice(2) + firstKey.slice(2), + ...remainingKeys ) - - return path.join(firstKeyDir, ...remainingKeys) } } diff --git a/packages/automerge-repo-storage-nodefs/test/NodeFSStorageAdapter.test.ts b/packages/automerge-repo-storage-nodefs/test/NodeFSStorageAdapter.test.ts new file mode 100644 index 000000000..fc657beaa --- /dev/null +++ b/packages/automerge-repo-storage-nodefs/test/NodeFSStorageAdapter.test.ts @@ -0,0 +1,19 @@ +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" +import { describe } from "vitest" +import { runStorageAdapterTests } from "../../automerge-repo/src/helpers/tests/storage-adapter-tests" +import { NodeFSStorageAdapter } from "../src" + +describe("NodeFSStorageAdapter", () => { + const setup = async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "automerge-repo-tests")) + const teardown = () => { + fs.rmSync(dir, { force: true, recursive: true }) + } + const adapter = new NodeFSStorageAdapter(dir) + return { adapter, teardown } + } + + runStorageAdapterTests(setup) +}) diff --git a/packages/automerge-repo-storage-nodefs/tsconfig.json b/packages/automerge-repo-storage-nodefs/tsconfig.json index 18d3db25a..c52bdbdfe 100644 --- a/packages/automerge-repo-storage-nodefs/tsconfig.json +++ b/packages/automerge-repo-storage-nodefs/tsconfig.json @@ -1,7 +1,6 @@ { "compilerOptions": { "target": "ESNext", - "jsx": "react", "module": "NodeNext", "moduleResolution": "Node16", "declaration": true, diff --git a/packages/automerge-repo-storage-nodefs/vitest.config.ts b/packages/automerge-repo-storage-nodefs/vitest.config.ts new file mode 100644 index 000000000..4ed7cdb32 --- /dev/null +++ b/packages/automerge-repo-storage-nodefs/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig, mergeConfig } from "vitest/config" +import rootConfig from "../../vitest.config" + +export default mergeConfig( + rootConfig, + defineConfig({ + test: { + environment: "node", + }, + }) +) diff --git a/packages/automerge-repo/src/helpers/tests/network-adapter-tests.ts b/packages/automerge-repo/src/helpers/tests/network-adapter-tests.ts index d5c89189f..1a13de6d9 100644 --- a/packages/automerge-repo/src/helpers/tests/network-adapter-tests.ts +++ b/packages/automerge-repo/src/helpers/tests/network-adapter-tests.ts @@ -17,7 +17,7 @@ import { pause } from "../pause.js" * - `teardown`: An optional function that will be called after the tests have run. This can be used * to clean up any resources that were created during the test. */ -export function runAdapterTests(_setup: SetupFn, title?: string): void { +export function runNetworkAdapterTests(_setup: SetupFn, title?: string): void { // Wrap the provided setup function const setup = async () => { const { adapters, teardown = NO_OP } = await _setup() @@ -28,7 +28,9 @@ export function runAdapterTests(_setup: SetupFn, title?: string): void { return { adapters: [a, b, c], teardown } } - describe(`Adapter acceptance tests ${title ? `(${title})` : ""}`, () => { + describe(`Network adapter acceptance tests ${ + title ? `(${title})` : "" + }`, () => { it("can sync 2 repos", async () => { const doTest = async ( a: NetworkAdapterInterface[], diff --git a/packages/automerge-repo/src/helpers/tests/storage-adapter-tests.ts b/packages/automerge-repo/src/helpers/tests/storage-adapter-tests.ts new file mode 100644 index 000000000..9d077bf8c --- /dev/null +++ b/packages/automerge-repo/src/helpers/tests/storage-adapter-tests.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest" + +import type { StorageAdapterInterface } from "../../storage/StorageAdapterInterface.js" + +const PAYLOAD_A = () => new Uint8Array([0, 1, 127, 99, 154, 235]) +const PAYLOAD_B = () => new Uint8Array([1, 76, 160, 53, 57, 10, 230]) +const PAYLOAD_C = () => new Uint8Array([2, 111, 74, 131, 236, 96, 142, 193]) + +const LARGE_PAYLOAD = new Uint8Array(100000).map(() => Math.random() * 256) + +export function runStorageAdapterTests(_setup: SetupFn, title?: string): void { + const setup = async () => { + const { adapter, teardown = NO_OP } = await _setup() + return { adapter, teardown } + } + + describe(`Network adapter acceptance tests ${ + title ? `(${title})` : "" + }`, () => { + describe("load", () => { + it("should return undefined if there is no data", async () => { + const { adapter, teardown } = await setup() + + const actual = await adapter.load(["AAAAA", "sync-state", "xxxxx"]) + expect(actual).toBeUndefined() + + teardown() + }) + }) + + describe("save and load", () => { + it("should return data that was saved", async () => { + const { adapter, teardown } = await setup() + + await adapter.save(["storage-adapter-id"], PAYLOAD_A()) + const actual = await adapter.load(["storage-adapter-id"]) + expect(actual).toStrictEqual(PAYLOAD_A()) + + teardown() + }) + + it("should work with composite keys", async () => { + const { adapter, teardown } = await setup() + + await adapter.save(["AAAAA", "sync-state", "xxxxx"], PAYLOAD_A()) + const actual = await adapter.load(["AAAAA", "sync-state", "xxxxx"]) + expect(actual).toStrictEqual(PAYLOAD_A()) + + teardown() + }) + + it("should work with a large payload", async () => { + const { adapter, teardown } = await setup() + + await adapter.save(["AAAAA", "sync-state", "xxxxx"], LARGE_PAYLOAD) + const actual = await adapter.load(["AAAAA", "sync-state", "xxxxx"]) + expect(actual).toStrictEqual(LARGE_PAYLOAD) + + teardown() + }) + }) + + describe("loadRange", () => { + it("should return an empty array if there is no data", async () => { + const { adapter, teardown } = await setup() + + expect(await adapter.loadRange(["AAAAA"])).toStrictEqual([]) + + teardown() + }) + }) + + describe("save and loadRange", () => { + it("should return all the data that matches the key", async () => { + const { adapter, teardown } = await setup() + + await adapter.save(["AAAAA", "sync-state", "xxxxx"], PAYLOAD_A()) + await adapter.save(["AAAAA", "snapshot", "yyyyy"], PAYLOAD_B()) + await adapter.save(["AAAAA", "sync-state", "zzzzz"], PAYLOAD_C()) + + expect(await adapter.loadRange(["AAAAA"])).toStrictEqual( + expect.arrayContaining([ + { key: ["AAAAA", "sync-state", "xxxxx"], data: PAYLOAD_A() }, + { key: ["AAAAA", "snapshot", "yyyyy"], data: PAYLOAD_B() }, + { key: ["AAAAA", "sync-state", "zzzzz"], data: PAYLOAD_C() }, + ]) + ) + + expect(await adapter.loadRange(["AAAAA", "sync-state"])).toStrictEqual( + expect.arrayContaining([ + { key: ["AAAAA", "sync-state", "xxxxx"], data: PAYLOAD_A() }, + { key: ["AAAAA", "sync-state", "zzzzz"], data: PAYLOAD_C() }, + ]) + ) + + teardown() + }) + + it("should only load values that match they key", async () => { + const { adapter, teardown } = await setup() + + await adapter.save(["AAAAA", "sync-state", "xxxxx"], PAYLOAD_A()) + await adapter.save(["BBBBB", "sync-state", "zzzzz"], PAYLOAD_C()) + + const actual = await adapter.loadRange(["AAAAA"]) + expect(actual).toStrictEqual( + expect.arrayContaining([ + { key: ["AAAAA", "sync-state", "xxxxx"], data: PAYLOAD_A() }, + ]) + ) + expect(actual).toStrictEqual( + expect.not.arrayContaining([ + { key: ["BBBBB", "sync-state", "zzzzz"], data: PAYLOAD_C() }, + ]) + ) + + teardown() + }) + }) + + describe("save and remove", () => { + it("after removing, should be empty", async () => { + const { adapter, teardown } = await setup() + + await adapter.save(["AAAAA", "snapshot", "xxxxx"], PAYLOAD_A()) + await adapter.remove(["AAAAA", "snapshot", "xxxxx"]) + + expect(await adapter.loadRange(["AAAAA"])).toStrictEqual([]) + expect( + await adapter.load(["AAAAA", "snapshot", "xxxxx"]) + ).toBeUndefined() + + teardown() + }) + }) + + describe("save and save", () => { + it("should overwrite data saved with the same key", async () => { + const { adapter, teardown } = await setup() + + await adapter.save(["AAAAA", "sync-state", "xxxxx"], PAYLOAD_A()) + await adapter.save(["AAAAA", "sync-state", "xxxxx"], PAYLOAD_B()) + + expect(await adapter.loadRange(["AAAAA", "sync-state"])).toStrictEqual([ + { key: ["AAAAA", "sync-state", "xxxxx"], data: PAYLOAD_B() }, + ]) + + teardown() + }) + }) + + describe("removeRange", () => { + it("should remove a range of records", async () => { + const { adapter, teardown } = await setup() + + await adapter.save(["AAAAA", "sync-state", "xxxxx"], PAYLOAD_A()) + await adapter.save(["AAAAA", "snapshot", "yyyyy"], PAYLOAD_B()) + await adapter.save(["AAAAA", "sync-state", "zzzzz"], PAYLOAD_C()) + + await adapter.removeRange(["AAAAA", "sync-state"]) + + expect(await adapter.loadRange(["AAAAA"])).toStrictEqual([ + { key: ["AAAAA", "snapshot", "yyyyy"], data: PAYLOAD_B() }, + ]) + + teardown() + }) + + it("should not remove records that don't match", async () => { + const { adapter, teardown } = await setup() + + await adapter.save(["AAAAA", "sync-state", "xxxxx"], PAYLOAD_A()) + await adapter.save(["BBBBB", "sync-state", "zzzzz"], PAYLOAD_B()) + + await adapter.removeRange(["AAAAA"]) + + const actual = await adapter.loadRange(["BBBBB"]) + expect(actual).toStrictEqual([ + { key: ["BBBBB", "sync-state", "zzzzz"], data: PAYLOAD_B() }, + ]) + + teardown() + }) + }) + }) +} + +const NO_OP = () => {} + +export type SetupFn = () => Promise<{ + adapter: StorageAdapterInterface + teardown?: () => void +}> diff --git a/packages/automerge-repo/test/DummyStorageAdapter.test.ts b/packages/automerge-repo/test/DummyStorageAdapter.test.ts new file mode 100644 index 000000000..3c6ddaed7 --- /dev/null +++ b/packages/automerge-repo/test/DummyStorageAdapter.test.ts @@ -0,0 +1,11 @@ +import { beforeEach, describe } from "vitest" +import { DummyStorageAdapter } from "./helpers/DummyStorageAdapter.js" +import { runStorageAdapterTests } from "../src/helpers/tests/storage-adapter-tests.js" + +describe("DummyStorageAdapter", () => { + const setup = async () => ({ + adapter: new DummyStorageAdapter(), + }) + + runStorageAdapterTests(setup, "DummyStorageAdapter") +})