diff --git a/SPEC/REFS.md b/SPEC/REFS.md new file mode 100644 index 000000000..e86fea704 --- /dev/null +++ b/SPEC/REFS.md @@ -0,0 +1,181 @@ +# Refs API + +* [refs](#refs) +* [refsReadableStream](#refsreadablestream) +* [refsPullStream](#refspullstream) +* [refs.local](#refslocal) +* [refs.localReadableStream](#refslocalreadablestream) +* [refs.localPullStream](#refslocalpullstream) + +#### `refs` + +> Get links (references) from an object. + +##### `ipfs.refs(ipfsPath, [options], [callback])` + +`ipfsPath` can be of type: + +- [`cid`][cid] of type: + - a [CID](https://github.com/ipfs/js-cid) instance + - [Buffer][b], the raw Buffer of the cid + - String, the base58 encoded version of the cid +- String, including the ipfs handler, a cid and a path to traverse to, ie: + - '/ipfs/QmXEmhrMpbVvTh61FNAxP9nU7ygVtyvZA8HZDUaqQCAb66' + - '/ipfs/QmXEmhrMpbVvTh61FNAxP9nU7ygVtyvZA8HZDUaqQCAb66/a.txt' + - 'QmXEmhrMpbVvTh61FNAxP9nU7ygVtyvZA8HZDUaqQCAb66/a.txt' + +`options` is an optional object that may contain the following keys: + - `recursive (false)`: recursively list references of child nodes + - `unique (false)`: omit duplicate references from output + - `format ("")`: output edges with given format. Available tokens: ``, ``, `` + - `edges (false)`: output references in edge format: `" -> "` + - `maxDepth (1)`: only for recursive refs, limits fetch and listing to the given depth + +`callback` must follow `function (err, refs) {}` signature, where `err` is an error if the operation was not successful and `refs` is an array of `{ ref: "myref", err: "error msg" }` + +If no `callback` is passed, a promise is returned. + +**Example:** + +```JavaScript +ipfs.refs(ipfsPath, { recursive: true }, function (err, refs) { + if (err) { + throw err + } + + for (const ref of refs) { + if (ref.err) { + console.error(ref.err) + } else { + console.log(ref.ref) + // output: "QmHash" + } + } +}) +``` + +#### `refsReadableStream` + +> Output references using a [Readable Stream][rs] + +##### `ipfs.refsReadableStream(ipfsPath, [options])` -> [Readable Stream][rs] + +`options` is an optional object argument identical to the options for [ipfs.refs](#refs) + +**Example:** + +```JavaScript +const stream = ipfs.refsReadableStream(ipfsPath, { recursive: true }) +stream.on('data', function (ref) { + // 'ref' will be of the form + // { + // ref: 'QmHash', + // err: 'err message' + // } +}) +``` + +#### `refsPullStream` + +> Output references using a [Pull Stream][ps]. + +##### `ipfs.refsReadableStream(ipfsPath, [options])` -> [Pull Stream][ps] + +`options` is an optional object argument identical to the options for [ipfs.refs](#refs) + +**Example:** + +```JavaScript +const stream = ipfs.refsPullStream(ipfsPath, { recursive: true }) + +pull( + stream, + pull.collect((err, values) => { + // values will be an array of objects, each one of the form + // { + // ref: 'QmHash', + // err: 'err message' + // } + }) +) +``` + +#### `refs.local` + +> Output all local references (CIDs of all blocks in the blockstore) + +##### `ipfs.refs.local([callback])` + +`callback` must follow `function (err, refs) {}` signature, where `err` is an error if the operation was not successful and `refs` is an array of `{ ref: "myref", err: "error msg" }` + +If no `callback` is passed, a promise is returned. + +**Example:** + +```JavaScript +ipfs.refs.local(function (err, refs) { + if (err) { + throw err + } + + for (const ref of refs) { + if (ref.err) { + console.error(ref.err) + } else { + console.log(ref.ref) + // output: "QmHash" + } + } +}) +``` + +#### `refs.localReadableStream` + +> Output all local references using a [Readable Stream][rs] + +##### `ipfs.localReadableStream()` -> [Readable Stream][rs] + +**Example:** + +```JavaScript +const stream = ipfs.refs.localReadableStream() +stream.on('data', function (ref) { + // 'ref' will be of the form + // { + // ref: 'QmHash', + // err: 'err message' + // } +}) +``` + +#### `refs.localPullStream` + +> Output all local references using a [Pull Stream][ps]. + +##### `ipfs.refs.localReadableStream()` -> [Pull Stream][ps] + +**Example:** + +```JavaScript +const stream = ipfs.refs.localPullStream() + +pull( + stream, + pull.collect((err, values) => { + // values will be an array of objects, each one of the form + // { + // ref: 'QmHash', + // err: 'err message' + // } + }) +) +``` + +A great source of [examples][] can be found in the tests for this API. + +[examples]: https://github.com/ipfs/interface-ipfs-core/blob/master/src/files-regular +[b]: https://www.npmjs.com/package/buffer +[rs]: https://www.npmjs.com/package/readable-stream +[ps]: https://www.npmjs.com/package/pull-stream +[cid]: https://www.npmjs.com/package/cids +[blob]: https://developer.mozilla.org/en-US/docs/Web/API/Blob diff --git a/src/files-regular/index.js b/src/files-regular/index.js index 656fe371a..d097ad9c9 100644 --- a/src/files-regular/index.js +++ b/src/files-regular/index.js @@ -17,7 +17,13 @@ const tests = { getPullStream: require('./get-pull-stream'), ls: require('./ls'), lsReadableStream: require('./ls-readable-stream'), - lsPullStream: require('./ls-pull-stream') + lsPullStream: require('./ls-pull-stream'), + refs: require('./refs'), + refsReadableStream: require('./refs-readable-stream'), + refsPullStream: require('./refs-pull-stream'), + refsLocal: require('./refs-local'), + refsLocalPullStream: require('./refs-local-pull-stream'), + refsLocalReadableStream: require('./refs-local-readable-stream') } module.exports = createSuite(tests) diff --git a/src/files-regular/refs-local-pull-stream.js b/src/files-regular/refs-local-pull-stream.js new file mode 100644 index 000000000..0f2b69774 --- /dev/null +++ b/src/files-regular/refs-local-pull-stream.js @@ -0,0 +1,14 @@ +/* eslint-env mocha */ +'use strict' + +const pull = require('pull-stream') + +module.exports = (createCommon, options) => { + const ipfsRefsLocal = (ipfs) => { + return (cb) => { + const stream = ipfs.refs.localPullStream() + pull(stream, pull.collect(cb)) + } + } + require('./refs-local-tests')(createCommon, '.refs.localPullStream', ipfsRefsLocal, options) +} diff --git a/src/files-regular/refs-local-readable-stream.js b/src/files-regular/refs-local-readable-stream.js new file mode 100644 index 000000000..9b1fbec7b --- /dev/null +++ b/src/files-regular/refs-local-readable-stream.js @@ -0,0 +1,15 @@ +/* eslint-env mocha */ +'use strict' + +const concat = require('concat-stream') + +module.exports = (createCommon, options) => { + const ipfsRefsLocal = (ipfs) => { + return (cb) => { + const stream = ipfs.refs.localReadableStream() + stream.on('error', cb) + stream.pipe(concat((refs) => cb(null, refs))) + } + } + require('./refs-local-tests')(createCommon, '.refs.localReadableStream', ipfsRefsLocal, options) +} diff --git a/src/files-regular/refs-local-tests.js b/src/files-regular/refs-local-tests.js new file mode 100644 index 000000000..af6f7fcb8 --- /dev/null +++ b/src/files-regular/refs-local-tests.js @@ -0,0 +1,60 @@ +/* eslint-env mocha */ +'use strict' + +const { fixtures } = require('./utils') +const { getDescribe, getIt, expect } = require('../utils/mocha') + +module.exports = (createCommon, suiteName, ipfsRefsLocal, options) => { + const describe = getDescribe(options) + const it = getIt(options) + const common = createCommon() + + describe(suiteName, function () { + this.timeout(40 * 1000) + + let ipfs + + before(function (done) { + // CI takes longer to instantiate the daemon, so we need to increase the + // timeout for the before step + this.timeout(60 * 1000) + + common.setup((err, factory) => { + expect(err).to.not.exist() + factory.spawnNode((err, node) => { + expect(err).to.not.exist() + ipfs = node + done() + }) + }) + }) + + after((done) => common.teardown(done)) + + it('should get local refs', function (done) { + const content = (name) => ({ + path: `test-folder/${name}`, + content: fixtures.directory.files[name] + }) + + const dirs = [ + content('pp.txt'), + content('holmes.txt') + ] + + ipfs.add(dirs, (err, res) => { + expect(err).to.not.exist() + + ipfsRefsLocal(ipfs)((err, refs) => { + expect(err).to.not.exist() + + const cids = refs.map(r => r.ref) + expect(cids).to.include('QmVwdDCY4SPGVFnNCiZnX5CtzwWDn6kAM98JXzKxE3kCmn') + expect(cids).to.include('QmR4nFjTu18TyANgC65ArNWp5Yaab1gPzQ4D8zp7Kx3vhr') + + done() + }) + }) + }) + }) +} diff --git a/src/files-regular/refs-local.js b/src/files-regular/refs-local.js new file mode 100644 index 000000000..d3f0b8150 --- /dev/null +++ b/src/files-regular/refs-local.js @@ -0,0 +1,7 @@ +/* eslint-env mocha */ +'use strict' + +module.exports = (createCommon, options) => { + const ipfsRefsLocal = (ipfs) => (cb) => ipfs.refs.local(cb) + require('./refs-local-tests')(createCommon, '.refs.local', ipfsRefsLocal, options) +} diff --git a/src/files-regular/refs-pull-stream.js b/src/files-regular/refs-pull-stream.js new file mode 100644 index 000000000..d26027371 --- /dev/null +++ b/src/files-regular/refs-pull-stream.js @@ -0,0 +1,14 @@ +/* eslint-env mocha */ +'use strict' + +const pull = require('pull-stream') + +module.exports = (createCommon, options) => { + const ipfsRefs = (ipfs) => { + return (path, params, cb) => { + const stream = ipfs.refsPullStream(path, params) + pull(stream, pull.collect(cb)) + } + } + require('./refs-tests')(createCommon, '.refsPullStream', ipfsRefs, options) +} diff --git a/src/files-regular/refs-readable-stream.js b/src/files-regular/refs-readable-stream.js new file mode 100644 index 000000000..23bc40065 --- /dev/null +++ b/src/files-regular/refs-readable-stream.js @@ -0,0 +1,15 @@ +/* eslint-env mocha */ +'use strict' + +const concat = require('concat-stream') + +module.exports = (createCommon, options) => { + const ipfsRefs = (ipfs) => { + return (path, params, cb) => { + const stream = ipfs.refsReadableStream(path, params) + stream.on('error', cb) + stream.pipe(concat((refs) => cb(null, refs))) + } + } + require('./refs-tests')(createCommon, '.refsReadableStream', ipfsRefs, options) +} diff --git a/src/files-regular/refs-tests.js b/src/files-regular/refs-tests.js new file mode 100644 index 000000000..08414888f --- /dev/null +++ b/src/files-regular/refs-tests.js @@ -0,0 +1,395 @@ +/* eslint-env mocha */ +'use strict' + +const map = require('async/map') +const { getDescribe, getIt, expect } = require('../utils/mocha') + +module.exports = (createCommon, suiteName, ipfsRefs, options) => { + const describe = getDescribe(options) + const it = getIt(options) + const common = createCommon() + + describe(suiteName, function () { + this.timeout(40 * 1000) + + let ipfs, pbRootCb, dagRootCid + + before(function (done) { + // CI takes longer to instantiate the daemon, so we need to increase the + // timeout for the before step + this.timeout(60 * 1000) + + common.setup((err, factory) => { + expect(err).to.not.exist() + factory.spawnNode((err, node) => { + expect(err).to.not.exist() + ipfs = node + done() + }) + }) + }) + + before(function (done) { + loadPbContent(ipfs, getMockObjects(), (err, cid) => { + expect(err).to.not.exist() + pbRootCb = cid + done() + }) + }) + + before(function (done) { + loadDagContent(ipfs, getMockObjects(), (err, cid) => { + expect(err).to.not.exist() + dagRootCid = cid + done() + }) + }) + + after((done) => common.teardown(done)) + + for (const [name, options] of Object.entries(getRefsTests())) { + const { path, params, expected, expectError, expectTimeout } = options + // eslint-disable-next-line no-loop-func + it(name, function (done) { + this.timeout(20 * 1000) + + // If we're expecting a timeout, call done when it expires + let timeout + if (expectTimeout) { + timeout = setTimeout(() => { + done() + done = null + }, expectTimeout) + } + + // Call out to IPFS + const p = (path ? path(pbRootCb) : pbRootCb) + ipfsRefs(ipfs)(p, params, (err, refs) => { + if (!done) { + // Already timed out + return + } + + if (expectError) { + // Expected an error + expect(err).to.exist() + return done() + } + + if (expectTimeout && !err) { + // Expected a timeout but there wasn't one + return expect.fail('Expected timeout error') + } + + // Check there was no error and the refs match what was expected + expect(err).to.not.exist() + expect(refs.map(r => r.ref)).to.eql(expected) + + // Clear any pending timeout + clearTimeout(timeout) + + done() + }) + }) + } + + it('dag refs test', function (done) { + this.timeout(20 * 1000) + + // Call out to IPFS + ipfsRefs(ipfs)(`/ipfs/${dagRootCid}`, { recursive: true }, (err, refs) => { + // Check there was no error and the refs match what was expected + expect(err).to.not.exist() + expect(refs.map(r => r.ref).sort()).to.eql([ + 'QmPDqvcuA4AkhBLBuh2y49yhUB98rCnxPxa3eVNC1kAbSC', + 'QmVwtsLUHurA6wUirPSdGeEW5tfBEqenXpeRaqr8XN7bNY', + 'QmXGL3ZdYV5rNLCfHe1QsFSQGekRFzgbBu1B3XGZ7DV9fd', + 'QmcSVZRN5E814KkPy4EHnftNAR7htbFvVhRKKqFs4FBwDG', + 'QmcSVZRN5E814KkPy4EHnftNAR7htbFvVhRKKqFs4FBwDG', + 'QmdBcHbK7uDQav8YrHsfKju3EKn48knxjd96KRMFs3gtS9', + 'QmeX96opBHZHLySMFoNiWS5msxjyX6rqtr3Rr1u7uxn7zJ', + 'Qmf8MwTnY7VdcnF8WcoJ3GB24NmNd1HsGzuEWCtUYDP38x', + 'zdpuAkqPgGuEFBFLcixZyFezWw3bsGUWVS6W7c8YhV5sdAc6E', + 'zdpuArVVBgigTbs6FdyqFFWUSsXymdruTtCVoboc91L3WTXi1', + 'zdpuAsrruPqzPDYs9c1FGNR5Wuyx8on64no6z62SRPv3viHGL', + 'zdpuAxTXSfaHaZNed3JG2WvcYNgd64v27ztB2zknrz5noPhz5' + ]) + + done() + }) + }) + }) +} + +function getMockObjects () { + return { + animals: { + land: { + 'african.txt': ['elephant', 'rhinocerous'], + 'americas.txt': ['ñandu', 'tapir'], + 'australian.txt': ['emu', 'kangaroo'] + }, + sea: { + 'atlantic.txt': ['dolphin', 'whale'], + 'indian.txt': ['cuttlefish', 'octopus'] + } + }, + fruits: { + 'tropical.txt': ['banana', 'pineapple'] + }, + 'atlantic-animals': ['dolphin', 'whale'], + 'mushroom.txt': ['mushroom'] + } +} + +function getRefsTests () { + return { + 'prints added files': { + params: {}, + expected: [ + 'QmYEJ7qQNZUvBnv4SZ3rEbksagaan3sGvnUq948vSG8Z34', + 'QmUXzZKa3xhTauLektUiK4GiogHskuz1c57CnnoP4TgYJD', + 'QmYLvZrFn8KE2bcJ9UFhthScBVbbcXEgkJnnCBeKWYkpuQ', + 'QmRfqT4uTUgFXhWbfBZm6eZxi2FQ8pqYK5tcWRyTZ7RcgY' + ] + }, + + 'prints files in edges format': { + params: { edges: true }, + expected: [ + 'Qmd5MhNjx3NSZm3L2QKG1TFvqkTRbtZwGJinqEfqpfHH7s -> QmYEJ7qQNZUvBnv4SZ3rEbksagaan3sGvnUq948vSG8Z34', + 'Qmd5MhNjx3NSZm3L2QKG1TFvqkTRbtZwGJinqEfqpfHH7s -> QmUXzZKa3xhTauLektUiK4GiogHskuz1c57CnnoP4TgYJD', + 'Qmd5MhNjx3NSZm3L2QKG1TFvqkTRbtZwGJinqEfqpfHH7s -> QmYLvZrFn8KE2bcJ9UFhthScBVbbcXEgkJnnCBeKWYkpuQ', + 'Qmd5MhNjx3NSZm3L2QKG1TFvqkTRbtZwGJinqEfqpfHH7s -> QmRfqT4uTUgFXhWbfBZm6eZxi2FQ8pqYK5tcWRyTZ7RcgY' + ] + }, + + 'prints files in custom format': { + params: { format: ': => ' }, + expected: [ + 'animals: Qmd5MhNjx3NSZm3L2QKG1TFvqkTRbtZwGJinqEfqpfHH7s => QmYEJ7qQNZUvBnv4SZ3rEbksagaan3sGvnUq948vSG8Z34', + 'atlantic-animals: Qmd5MhNjx3NSZm3L2QKG1TFvqkTRbtZwGJinqEfqpfHH7s => QmUXzZKa3xhTauLektUiK4GiogHskuz1c57CnnoP4TgYJD', + 'fruits: Qmd5MhNjx3NSZm3L2QKG1TFvqkTRbtZwGJinqEfqpfHH7s => QmYLvZrFn8KE2bcJ9UFhthScBVbbcXEgkJnnCBeKWYkpuQ', + 'mushroom.txt: Qmd5MhNjx3NSZm3L2QKG1TFvqkTRbtZwGJinqEfqpfHH7s => QmRfqT4uTUgFXhWbfBZm6eZxi2FQ8pqYK5tcWRyTZ7RcgY' + ] + }, + + 'follows a path, /': { + path: (cid) => `/ipfs/${cid}/animals`, + params: { format: '' }, + expected: [ + 'land', + 'sea' + ] + }, + + 'follows a path, //': { + path: (cid) => `/ipfs/${cid}/animals/land`, + params: { format: '' }, + expected: [ + 'african.txt', + 'americas.txt', + 'australian.txt' + ] + }, + + 'follows a path with recursion, /': { + path: (cid) => `/ipfs/${cid}/animals`, + params: { format: '', recursive: true }, + expected: [ + 'land', + 'african.txt', + 'americas.txt', + 'australian.txt', + 'sea', + 'atlantic.txt', + 'indian.txt' + ] + }, + + 'recursively follows folders, -r': { + params: { format: '', recursive: true }, + expected: [ + 'animals', + 'land', + 'african.txt', + 'americas.txt', + 'australian.txt', + 'sea', + 'atlantic.txt', + 'indian.txt', + 'atlantic-animals', + 'fruits', + 'tropical.txt', + 'mushroom.txt' + ] + }, + + 'recursive with unique option': { + params: { format: '', recursive: true, unique: true }, + expected: [ + 'animals', + 'land', + 'african.txt', + 'americas.txt', + 'australian.txt', + 'sea', + 'atlantic.txt', + 'indian.txt', + 'fruits', + 'tropical.txt', + 'mushroom.txt' + ] + }, + + 'max depth of 1': { + params: { format: '', recursive: true, maxDepth: 1 }, + expected: [ + 'animals', + 'atlantic-animals', + 'fruits', + 'mushroom.txt' + ] + }, + + 'max depth of 2': { + params: { format: '', recursive: true, maxDepth: 2 }, + expected: [ + 'animals', + 'land', + 'sea', + 'atlantic-animals', + 'fruits', + 'tropical.txt', + 'mushroom.txt' + ] + }, + + 'max depth of 3': { + params: { format: '', recursive: true, maxDepth: 3 }, + expected: [ + 'animals', + 'land', + 'african.txt', + 'americas.txt', + 'australian.txt', + 'sea', + 'atlantic.txt', + 'indian.txt', + 'atlantic-animals', + 'fruits', + 'tropical.txt', + 'mushroom.txt' + ] + }, + + 'max depth of 0': { + params: { recursive: true, maxDepth: 0 }, + expected: [] + }, + + 'follows a path with max depth 1, /': { + path: (cid) => `/ipfs/${cid}/animals`, + params: { format: '', recursive: true, maxDepth: 1 }, + expected: [ + 'land', + 'sea' + ] + }, + + 'follows a path with max depth 2, /': { + path: (cid) => `/ipfs/${cid}/animals`, + params: { format: '', recursive: true, maxDepth: 2 }, + expected: [ + 'land', + 'african.txt', + 'americas.txt', + 'australian.txt', + 'sea', + 'atlantic.txt', + 'indian.txt' + ] + }, + + 'prints refs for multiple paths': { + path: (cid) => [`/ipfs/${cid}/animals`, `/ipfs/${cid}/fruits`], + params: { format: '', recursive: true }, + expected: [ + 'land', + 'african.txt', + 'americas.txt', + 'australian.txt', + 'sea', + 'atlantic.txt', + 'indian.txt', + 'tropical.txt' + ] + }, + + 'cannot specify edges and format': { + params: { format: '', edges: true }, + expectError: true + }, + + 'prints nothing for non-existent hashes': { + path: () => 'QmYmW4HiZhotsoSqnv2o1oSssvkRM8b9RweBoH7ao5nki2', + expectTimeout: 4000 + } + } +} + +function loadPbContent (ipfs, node, callback) { + const store = { + putData: (data, cb) => ipfs.object.put({ Data: data, Links: [] }, cb), + putLinks: (links, cb) => { + ipfs.object.put({ + Data: '', + Links: links.map(({ name, cid }) => ({ Name: name, Hash: cid, Size: 8 })) + }, cb) + } + } + loadContent(ipfs, store, node, callback) +} + +function loadDagContent (ipfs, node, callback) { + const store = { + putData: (data, cb) => { + ipfs.add(Buffer.from(data), (err, res) => { + if (err) { + return callback(err) + } + return cb(null, res[0].hash) + }) + }, + putLinks: (links, cb) => { + const obj = {} + for (const { name, cid } of links) { + obj[name] = { '/': cid } + } + ipfs.dag.put(obj, cb) + } + } + loadContent(ipfs, store, node, callback) +} + +function loadContent (ipfs, store, node, callback) { + if (Array.isArray(node)) { + return store.putData(node.join('\n'), callback) + } + + if (typeof node === 'object') { + const entries = Object.entries(node) + const sorted = entries.sort((a, b) => a[0] > b[0] ? 1 : a[0] < b[0] ? -1 : 0) + map(sorted, ([name, child], cb) => { + loadContent(ipfs, store, child, (err, cid) => { + cb(err, { name, cid: cid && cid.toString() }) + }) + }, (err, res) => { + if (err) { + return callback(err) + } + + store.putLinks(res, callback) + }) + } +} diff --git a/src/files-regular/refs.js b/src/files-regular/refs.js new file mode 100644 index 000000000..41dd8c03a --- /dev/null +++ b/src/files-regular/refs.js @@ -0,0 +1,7 @@ +/* eslint-env mocha */ +'use strict' + +module.exports = (createCommon, options) => { + const ipfsRefs = (ipfs) => ipfs.refs.bind(ipfs) + require('./refs-tests')(createCommon, '.refs', ipfsRefs, options) +} diff --git a/src/object/links.js b/src/object/links.js index 04b757597..e9961a6e3 100644 --- a/src/object/links.js +++ b/src/object/links.js @@ -162,5 +162,42 @@ module.exports = (createCommon, options) => { }) }) }) + + it('should get links from CBOR object', (done) => { + const hashes = [] + ipfs.add(Buffer.from('test data'), (err, res1) => { + expect(err).to.not.exist() + hashes.push(res1[0].hash) + ipfs.add(Buffer.from('more test data'), (err, res2) => { + hashes.push(res2[0].hash) + expect(err).to.not.exist() + const obj = { + some: 'data', + mylink: { '/': hashes[0] }, + myobj: { + anotherLink: { '/': hashes[1] } + } + } + ipfs.dag.put(obj, (err, cid) => { + expect(err).to.not.exist() + ipfs.object.links(cid, (err, links) => { + expect(err).to.not.exist() + expect(links.length).to.eql(2) + + // TODO: js-ipfs succeeds but go returns empty strings for link name + // const names = [links[0].name, links[1].name] + // expect(names).includes('mylink') + // expect(names).includes('myobj/anotherLink') + + const cids = [links[0].cid.toString(), links[1].cid.toString()] + expect(cids).includes(hashes[0]) + expect(cids).includes(hashes[1]) + + done() + }) + }) + }) + }) + }) }) }