@automerge/automerge-repo 1.1.4 → 1.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -22
- package/dist/DocHandle.d.ts +124 -100
- package/dist/DocHandle.d.ts.map +1 -1
- package/dist/DocHandle.js +239 -231
- package/dist/Repo.d.ts +10 -3
- package/dist/Repo.d.ts.map +1 -1
- package/dist/Repo.js +22 -1
- package/dist/helpers/arraysAreEqual.d.ts.map +1 -1
- package/dist/helpers/debounce.d.ts.map +1 -1
- package/dist/helpers/tests/network-adapter-tests.d.ts +1 -1
- package/dist/helpers/tests/network-adapter-tests.d.ts.map +1 -1
- package/dist/helpers/tests/network-adapter-tests.js +2 -2
- package/dist/helpers/tests/storage-adapter-tests.d.ts +7 -0
- package/dist/helpers/tests/storage-adapter-tests.d.ts.map +1 -0
- package/dist/helpers/tests/storage-adapter-tests.js +128 -0
- package/dist/helpers/throttle.d.ts.map +1 -1
- package/dist/helpers/withTimeout.d.ts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/synchronizer/DocSynchronizer.js +1 -1
- package/package.json +4 -4
- package/src/DocHandle.ts +325 -375
- package/src/Repo.ts +35 -8
- package/src/helpers/tests/network-adapter-tests.ts +4 -2
- package/src/helpers/tests/storage-adapter-tests.ts +193 -0
- package/src/index.ts +43 -0
- package/src/synchronizer/DocSynchronizer.ts +1 -1
- package/test/CollectionSynchronizer.test.ts +1 -3
- package/test/DocHandle.test.ts +19 -1
- package/test/DocSynchronizer.test.ts +1 -4
- package/test/DummyStorageAdapter.test.ts +11 -0
- package/test/Repo.test.ts +179 -53
- package/test/helpers/DummyNetworkAdapter.ts +20 -18
- package/test/helpers/DummyStorageAdapter.ts +5 -1
- package/test/remoteHeads.test.ts +1 -1
- package/tsconfig.json +1 -0
package/src/Repo.ts
CHANGED
|
@@ -2,15 +2,18 @@ import { next as Automerge } from "@automerge/automerge"
|
|
|
2
2
|
import debug from "debug"
|
|
3
3
|
import { EventEmitter } from "eventemitter3"
|
|
4
4
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
generateAutomergeUrl,
|
|
6
|
+
interpretAsDocumentId,
|
|
7
|
+
parseAutomergeUrl,
|
|
8
8
|
} from "./AutomergeUrl.js"
|
|
9
9
|
import { DocHandle, DocHandleEncodedChangePayload } from "./DocHandle.js"
|
|
10
10
|
import { RemoteHeadsSubscriptions } from "./RemoteHeadsSubscriptions.js"
|
|
11
11
|
import { headsAreSame } from "./helpers/headsAreSame.js"
|
|
12
12
|
import { throttle } from "./helpers/throttle.js"
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
NetworkAdapterInterface,
|
|
15
|
+
type PeerMetadata,
|
|
16
|
+
} from "./network/NetworkAdapterInterface.js"
|
|
14
17
|
import { NetworkSubsystem } from "./network/NetworkSubsystem.js"
|
|
15
18
|
import { RepoMessage } from "./network/messages.js"
|
|
16
19
|
import { StorageAdapterInterface } from "./storage/StorageAdapterInterface.js"
|
|
@@ -57,12 +60,12 @@ export class Repo extends EventEmitter<RepoEvents> {
|
|
|
57
60
|
|
|
58
61
|
constructor({
|
|
59
62
|
storage,
|
|
60
|
-
network,
|
|
63
|
+
network = [],
|
|
61
64
|
peerId,
|
|
62
65
|
sharePolicy,
|
|
63
66
|
isEphemeral = storage === undefined,
|
|
64
67
|
enableRemoteHeadsGossiping = false,
|
|
65
|
-
}: RepoConfig) {
|
|
68
|
+
}: RepoConfig = {}) {
|
|
66
69
|
super()
|
|
67
70
|
this.#remoteHeadsGossipingEnabled = enableRemoteHeadsGossiping
|
|
68
71
|
this.#log = debug(`automerge-repo:repo`)
|
|
@@ -502,6 +505,30 @@ export class Repo extends EventEmitter<RepoEvents> {
|
|
|
502
505
|
return this.storageSubsystem.id()
|
|
503
506
|
}
|
|
504
507
|
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Writes Documents to a disk.
|
|
511
|
+
* @hidden this API is experimental and may change.
|
|
512
|
+
* @param documents - if provided, only writes the specified documents.
|
|
513
|
+
* @returns Promise<void>
|
|
514
|
+
*/
|
|
515
|
+
async flush(documents?: DocumentId[]): Promise<void> {
|
|
516
|
+
if (!this.storageSubsystem) {
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
const handles = documents
|
|
520
|
+
? documents.map(id => this.#handleCache[id])
|
|
521
|
+
: Object.values(this.#handleCache)
|
|
522
|
+
await Promise.all(
|
|
523
|
+
handles.map(async handle => {
|
|
524
|
+
const doc = handle.docSync()
|
|
525
|
+
if (!doc) {
|
|
526
|
+
return
|
|
527
|
+
}
|
|
528
|
+
return this.storageSubsystem!.saveDoc(handle.documentId, doc)
|
|
529
|
+
})
|
|
530
|
+
)
|
|
531
|
+
}
|
|
505
532
|
}
|
|
506
533
|
|
|
507
534
|
export interface RepoConfig {
|
|
@@ -515,8 +542,8 @@ export interface RepoConfig {
|
|
|
515
542
|
/** A storage adapter can be provided, or not */
|
|
516
543
|
storage?: StorageAdapterInterface
|
|
517
544
|
|
|
518
|
-
/**
|
|
519
|
-
network
|
|
545
|
+
/** A list of network adapters (more can be added at runtime). */
|
|
546
|
+
network?: NetworkAdapterInterface[]
|
|
520
547
|
|
|
521
548
|
/**
|
|
522
549
|
* Normal peers typically share generously with everyone (meaning we sync all our documents with
|
|
@@ -17,7 +17,7 @@ import { pause } from "../pause.js"
|
|
|
17
17
|
* - `teardown`: An optional function that will be called after the tests have run. This can be used
|
|
18
18
|
* to clean up any resources that were created during the test.
|
|
19
19
|
*/
|
|
20
|
-
export function
|
|
20
|
+
export function runNetworkAdapterTests(_setup: SetupFn, title?: string): void {
|
|
21
21
|
// Wrap the provided setup function
|
|
22
22
|
const setup = async () => {
|
|
23
23
|
const { adapters, teardown = NO_OP } = await _setup()
|
|
@@ -28,7 +28,9 @@ export function runAdapterTests(_setup: SetupFn, title?: string): void {
|
|
|
28
28
|
return { adapters: [a, b, c], teardown }
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
describe(`
|
|
31
|
+
describe(`Network adapter acceptance tests ${
|
|
32
|
+
title ? `(${title})` : ""
|
|
33
|
+
}`, () => {
|
|
32
34
|
it("can sync 2 repos", async () => {
|
|
33
35
|
const doTest = async (
|
|
34
36
|
a: NetworkAdapterInterface[],
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
|
|
3
|
+
import type { StorageAdapterInterface } from "../../storage/StorageAdapterInterface.js"
|
|
4
|
+
|
|
5
|
+
const PAYLOAD_A = () => new Uint8Array([0, 1, 127, 99, 154, 235])
|
|
6
|
+
const PAYLOAD_B = () => new Uint8Array([1, 76, 160, 53, 57, 10, 230])
|
|
7
|
+
const PAYLOAD_C = () => new Uint8Array([2, 111, 74, 131, 236, 96, 142, 193])
|
|
8
|
+
|
|
9
|
+
const LARGE_PAYLOAD = new Uint8Array(100000).map(() => Math.random() * 256)
|
|
10
|
+
|
|
11
|
+
export function runStorageAdapterTests(_setup: SetupFn, title?: string): void {
|
|
12
|
+
const setup = async () => {
|
|
13
|
+
const { adapter, teardown = NO_OP } = await _setup()
|
|
14
|
+
return { adapter, teardown }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe(`Storage adapter acceptance tests ${
|
|
18
|
+
title ? `(${title})` : ""
|
|
19
|
+
}`, () => {
|
|
20
|
+
describe("load", () => {
|
|
21
|
+
it("should return undefined if there is no data", async () => {
|
|
22
|
+
const { adapter, teardown } = await setup()
|
|
23
|
+
|
|
24
|
+
const actual = await adapter.load(["AAAAA", "sync-state", "xxxxx"])
|
|
25
|
+
expect(actual).toBeUndefined()
|
|
26
|
+
|
|
27
|
+
teardown()
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe("save and load", () => {
|
|
32
|
+
it("should return data that was saved", async () => {
|
|
33
|
+
const { adapter, teardown } = await setup()
|
|
34
|
+
|
|
35
|
+
await adapter.save(["storage-adapter-id"], PAYLOAD_A())
|
|
36
|
+
const actual = await adapter.load(["storage-adapter-id"])
|
|
37
|
+
expect(actual).toStrictEqual(PAYLOAD_A())
|
|
38
|
+
|
|
39
|
+
teardown()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("should work with composite keys", async () => {
|
|
43
|
+
const { adapter, teardown } = await setup()
|
|
44
|
+
|
|
45
|
+
await adapter.save(["AAAAA", "sync-state", "xxxxx"], PAYLOAD_A())
|
|
46
|
+
const actual = await adapter.load(["AAAAA", "sync-state", "xxxxx"])
|
|
47
|
+
expect(actual).toStrictEqual(PAYLOAD_A())
|
|
48
|
+
|
|
49
|
+
teardown()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("should work with a large payload", async () => {
|
|
53
|
+
const { adapter, teardown } = await setup()
|
|
54
|
+
|
|
55
|
+
await adapter.save(["AAAAA", "sync-state", "xxxxx"], LARGE_PAYLOAD)
|
|
56
|
+
const actual = await adapter.load(["AAAAA", "sync-state", "xxxxx"])
|
|
57
|
+
expect(actual).toStrictEqual(LARGE_PAYLOAD)
|
|
58
|
+
|
|
59
|
+
teardown()
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe("loadRange", () => {
|
|
64
|
+
it("should return an empty array if there is no data", async () => {
|
|
65
|
+
const { adapter, teardown } = await setup()
|
|
66
|
+
|
|
67
|
+
expect(await adapter.loadRange(["AAAAA"])).toStrictEqual([])
|
|
68
|
+
|
|
69
|
+
teardown()
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe("save and loadRange", () => {
|
|
74
|
+
it("should return all the data that matches the key", async () => {
|
|
75
|
+
const { adapter, teardown } = await setup()
|
|
76
|
+
|
|
77
|
+
await adapter.save(["AAAAA", "sync-state", "xxxxx"], PAYLOAD_A())
|
|
78
|
+
await adapter.save(["AAAAA", "snapshot", "yyyyy"], PAYLOAD_B())
|
|
79
|
+
await adapter.save(["AAAAA", "sync-state", "zzzzz"], PAYLOAD_C())
|
|
80
|
+
|
|
81
|
+
expect(await adapter.loadRange(["AAAAA"])).toStrictEqual(
|
|
82
|
+
expect.arrayContaining([
|
|
83
|
+
{ key: ["AAAAA", "sync-state", "xxxxx"], data: PAYLOAD_A() },
|
|
84
|
+
{ key: ["AAAAA", "snapshot", "yyyyy"], data: PAYLOAD_B() },
|
|
85
|
+
{ key: ["AAAAA", "sync-state", "zzzzz"], data: PAYLOAD_C() },
|
|
86
|
+
])
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
expect(await adapter.loadRange(["AAAAA", "sync-state"])).toStrictEqual(
|
|
90
|
+
expect.arrayContaining([
|
|
91
|
+
{ key: ["AAAAA", "sync-state", "xxxxx"], data: PAYLOAD_A() },
|
|
92
|
+
{ key: ["AAAAA", "sync-state", "zzzzz"], data: PAYLOAD_C() },
|
|
93
|
+
])
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
teardown()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("should only load values that match they key", async () => {
|
|
100
|
+
const { adapter, teardown } = await setup()
|
|
101
|
+
|
|
102
|
+
await adapter.save(["AAAAA", "sync-state", "xxxxx"], PAYLOAD_A())
|
|
103
|
+
await adapter.save(["BBBBB", "sync-state", "zzzzz"], PAYLOAD_C())
|
|
104
|
+
|
|
105
|
+
const actual = await adapter.loadRange(["AAAAA"])
|
|
106
|
+
expect(actual).toStrictEqual(
|
|
107
|
+
expect.arrayContaining([
|
|
108
|
+
{ key: ["AAAAA", "sync-state", "xxxxx"], data: PAYLOAD_A() },
|
|
109
|
+
])
|
|
110
|
+
)
|
|
111
|
+
expect(actual).toStrictEqual(
|
|
112
|
+
expect.not.arrayContaining([
|
|
113
|
+
{ key: ["BBBBB", "sync-state", "zzzzz"], data: PAYLOAD_C() },
|
|
114
|
+
])
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
teardown()
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe("save and remove", () => {
|
|
122
|
+
it("after removing, should be empty", async () => {
|
|
123
|
+
const { adapter, teardown } = await setup()
|
|
124
|
+
|
|
125
|
+
await adapter.save(["AAAAA", "snapshot", "xxxxx"], PAYLOAD_A())
|
|
126
|
+
await adapter.remove(["AAAAA", "snapshot", "xxxxx"])
|
|
127
|
+
|
|
128
|
+
expect(await adapter.loadRange(["AAAAA"])).toStrictEqual([])
|
|
129
|
+
expect(
|
|
130
|
+
await adapter.load(["AAAAA", "snapshot", "xxxxx"])
|
|
131
|
+
).toBeUndefined()
|
|
132
|
+
|
|
133
|
+
teardown()
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe("save and save", () => {
|
|
138
|
+
it("should overwrite data saved with the same key", async () => {
|
|
139
|
+
const { adapter, teardown } = await setup()
|
|
140
|
+
|
|
141
|
+
await adapter.save(["AAAAA", "sync-state", "xxxxx"], PAYLOAD_A())
|
|
142
|
+
await adapter.save(["AAAAA", "sync-state", "xxxxx"], PAYLOAD_B())
|
|
143
|
+
|
|
144
|
+
expect(await adapter.loadRange(["AAAAA", "sync-state"])).toStrictEqual([
|
|
145
|
+
{ key: ["AAAAA", "sync-state", "xxxxx"], data: PAYLOAD_B() },
|
|
146
|
+
])
|
|
147
|
+
|
|
148
|
+
teardown()
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
describe("removeRange", () => {
|
|
153
|
+
it("should remove a range of records", async () => {
|
|
154
|
+
const { adapter, teardown } = await setup()
|
|
155
|
+
|
|
156
|
+
await adapter.save(["AAAAA", "sync-state", "xxxxx"], PAYLOAD_A())
|
|
157
|
+
await adapter.save(["AAAAA", "snapshot", "yyyyy"], PAYLOAD_B())
|
|
158
|
+
await adapter.save(["AAAAA", "sync-state", "zzzzz"], PAYLOAD_C())
|
|
159
|
+
|
|
160
|
+
await adapter.removeRange(["AAAAA", "sync-state"])
|
|
161
|
+
|
|
162
|
+
expect(await adapter.loadRange(["AAAAA"])).toStrictEqual([
|
|
163
|
+
{ key: ["AAAAA", "snapshot", "yyyyy"], data: PAYLOAD_B() },
|
|
164
|
+
])
|
|
165
|
+
|
|
166
|
+
teardown()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it("should not remove records that don't match", async () => {
|
|
170
|
+
const { adapter, teardown } = await setup()
|
|
171
|
+
|
|
172
|
+
await adapter.save(["AAAAA", "sync-state", "xxxxx"], PAYLOAD_A())
|
|
173
|
+
await adapter.save(["BBBBB", "sync-state", "zzzzz"], PAYLOAD_B())
|
|
174
|
+
|
|
175
|
+
await adapter.removeRange(["AAAAA"])
|
|
176
|
+
|
|
177
|
+
const actual = await adapter.loadRange(["BBBBB"])
|
|
178
|
+
expect(actual).toStrictEqual([
|
|
179
|
+
{ key: ["BBBBB", "sync-state", "zzzzz"], data: PAYLOAD_B() },
|
|
180
|
+
])
|
|
181
|
+
|
|
182
|
+
teardown()
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const NO_OP = () => {}
|
|
189
|
+
|
|
190
|
+
export type SetupFn = () => Promise<{
|
|
191
|
+
adapter: StorageAdapterInterface
|
|
192
|
+
teardown?: () => void
|
|
193
|
+
}>
|
package/src/index.ts
CHANGED
|
@@ -90,3 +90,46 @@ export type {
|
|
|
90
90
|
} from "./storage/types.js"
|
|
91
91
|
|
|
92
92
|
export * from "./types.js"
|
|
93
|
+
|
|
94
|
+
// export commonly used data types
|
|
95
|
+
export type { Counter, RawString, Cursor } from "@automerge/automerge/next"
|
|
96
|
+
|
|
97
|
+
// export some automerge API types
|
|
98
|
+
export type {
|
|
99
|
+
Doc,
|
|
100
|
+
Heads,
|
|
101
|
+
Patch,
|
|
102
|
+
PatchCallback,
|
|
103
|
+
Prop,
|
|
104
|
+
ActorId,
|
|
105
|
+
Change,
|
|
106
|
+
ChangeFn,
|
|
107
|
+
Mark,
|
|
108
|
+
MarkSet,
|
|
109
|
+
MarkRange,
|
|
110
|
+
MarkValue,
|
|
111
|
+
} from "@automerge/automerge/next"
|
|
112
|
+
|
|
113
|
+
// export a few utility functions that aren't in automerge-repo
|
|
114
|
+
// NB that these should probably all just be available via the dochandle
|
|
115
|
+
export {
|
|
116
|
+
getChanges,
|
|
117
|
+
getAllChanges,
|
|
118
|
+
applyChanges,
|
|
119
|
+
view,
|
|
120
|
+
getConflicts,
|
|
121
|
+
} from "@automerge/automerge/next"
|
|
122
|
+
|
|
123
|
+
// export type-specific utility functions
|
|
124
|
+
// these mostly can't be on the data-type in question because
|
|
125
|
+
// JS strings can't have methods added to them
|
|
126
|
+
export {
|
|
127
|
+
getCursor,
|
|
128
|
+
getCursorPosition,
|
|
129
|
+
splice,
|
|
130
|
+
updateText,
|
|
131
|
+
insertAt,
|
|
132
|
+
deleteAt,
|
|
133
|
+
mark,
|
|
134
|
+
unmark,
|
|
135
|
+
} from "@automerge/automerge/next"
|
|
@@ -228,7 +228,7 @@ export class DocSynchronizer extends Synchronizer {
|
|
|
228
228
|
|
|
229
229
|
beginSync(peerIds: PeerId[]) {
|
|
230
230
|
const noPeersWithDocument = peerIds.every(
|
|
231
|
-
|
|
231
|
+
peerId => this.#peerDocumentStatuses[peerId] in ["unavailable", "wants"]
|
|
232
232
|
)
|
|
233
233
|
|
|
234
234
|
// At this point if we don't have anything in our storage, we need to use an empty doc to sync
|
package/test/DocHandle.test.ts
CHANGED
|
@@ -72,6 +72,24 @@ describe("DocHandle", () => {
|
|
|
72
72
|
assert.equal(doc?.foo, "bar")
|
|
73
73
|
})
|
|
74
74
|
|
|
75
|
+
it("should return the heads when requested", async () => {
|
|
76
|
+
const handle = new DocHandle<TestDoc>(TEST_ID, {
|
|
77
|
+
isNew: true,
|
|
78
|
+
initialValue: { foo: "bar" },
|
|
79
|
+
})
|
|
80
|
+
assert.equal(handle.isReady(), true)
|
|
81
|
+
|
|
82
|
+
const heads = A.getHeads(handle.docSync())
|
|
83
|
+
assert.notDeepEqual(handle.heads(), [])
|
|
84
|
+
assert.deepEqual(heads, handle.heads())
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("should return undefined if the heads aren't loaded", async () => {
|
|
88
|
+
const handle = new DocHandle<TestDoc>(TEST_ID)
|
|
89
|
+
assert.equal(handle.isReady(), false)
|
|
90
|
+
assert.deepEqual(handle.heads(), undefined)
|
|
91
|
+
})
|
|
92
|
+
|
|
75
93
|
/**
|
|
76
94
|
* Once there's a Repo#stop API this case should be covered in accompanying
|
|
77
95
|
* tests and the following test removed.
|
|
@@ -319,7 +337,7 @@ describe("DocHandle", () => {
|
|
|
319
337
|
doc.foo = "bar"
|
|
320
338
|
})
|
|
321
339
|
|
|
322
|
-
const headsBefore =
|
|
340
|
+
const headsBefore = handle.heads()!
|
|
323
341
|
|
|
324
342
|
handle.change(doc => {
|
|
325
343
|
doc.foo = "rab"
|
|
@@ -68,10 +68,7 @@ describe("DocSynchronizer", () => {
|
|
|
68
68
|
|
|
69
69
|
assert.equal(message2.peerId, "alice")
|
|
70
70
|
assert.equal(message2.documentId, handle.documentId)
|
|
71
|
-
assert.deepEqual(
|
|
72
|
-
message2.syncState.lastSentHeads,
|
|
73
|
-
A.getHeads(handle.docSync())
|
|
74
|
-
)
|
|
71
|
+
assert.deepEqual(message2.syncState.lastSentHeads, handle.heads())
|
|
75
72
|
})
|
|
76
73
|
|
|
77
74
|
it("still syncs with a peer after it disconnects and reconnects", async () => {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { beforeEach, describe } from "vitest"
|
|
2
|
+
import { DummyStorageAdapter } from "./helpers/DummyStorageAdapter.js"
|
|
3
|
+
import { runStorageAdapterTests } from "../src/helpers/tests/storage-adapter-tests.js"
|
|
4
|
+
|
|
5
|
+
describe("DummyStorageAdapter", () => {
|
|
6
|
+
const setup = async () => ({
|
|
7
|
+
adapter: new DummyStorageAdapter(),
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
runStorageAdapterTests(setup, "DummyStorageAdapter")
|
|
11
|
+
})
|