|
|
@@ -76,19 +76,69 @@ describe('ProjectSnapshot', function () {
|
|
|
startVersion: 0,
|
|
|
}
|
|
|
|
|
|
- async function initializeSnapshot() {
|
|
|
- fetchMock.postOnce(`/project/${projectId}/flush`, 200)
|
|
|
- fetchMock.getOnce(`/project/${projectId}/latest/history`, { chunk })
|
|
|
- fetchMock.getOnce(
|
|
|
- `/project/${projectId}/blob/${files['main.tex'].hash}`,
|
|
|
- files['main.tex'].contents
|
|
|
- )
|
|
|
+ const changes = [
|
|
|
+ {
|
|
|
+ operations: [
|
|
|
+ {
|
|
|
+ pathname: 'hello.txt',
|
|
|
+ textOperation: ['Quote: ', files['hello.txt'].contents.length],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ pathname: 'goodbye.txt',
|
|
|
+ file: {
|
|
|
+ hash: files['goodbye.txt'].hash,
|
|
|
+ stringLength: files['goodbye.txt'].contents.length,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ timestamp: '2025-01-01T13:00:00.000Z',
|
|
|
+ },
|
|
|
+ ]
|
|
|
+
|
|
|
+ function mockFlush(
|
|
|
+ opts: { repeat?: number; failOnCall?: (call: number) => boolean } = {}
|
|
|
+ ) {
|
|
|
+ let currentCall = 0
|
|
|
+ const getResponse = () => {
|
|
|
+ currentCall += 1
|
|
|
+ return opts.failOnCall?.(currentCall) ? 500 : 200
|
|
|
+ }
|
|
|
+
|
|
|
+ fetchMock.post(`/project/${projectId}/flush`, getResponse, {
|
|
|
+ name: 'flush',
|
|
|
+ repeat: opts.repeat ?? 1,
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ function mockLatestChunk() {
|
|
|
fetchMock.getOnce(
|
|
|
- `/project/${projectId}/blob/${files['hello.txt'].hash}`,
|
|
|
- files['hello.txt'].contents
|
|
|
+ `/project/${projectId}/latest/history`,
|
|
|
+ { chunk },
|
|
|
+ { name: 'latest-chunk' }
|
|
|
)
|
|
|
+ }
|
|
|
+
|
|
|
+ function mockChanges() {
|
|
|
+ fetchMock.getOnce(`/project/${projectId}/changes?since=1`, changes, {
|
|
|
+ name: 'changes-1',
|
|
|
+ })
|
|
|
+ fetchMock.get(`/project/${projectId}/changes?since=2`, [], {
|
|
|
+ name: 'changes-2',
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ function mockBlobs(paths = Object.keys(files) as (keyof typeof files)[]) {
|
|
|
+ for (const path of paths) {
|
|
|
+ const file = files[path]
|
|
|
+ fetchMock.get(`/project/${projectId}/blob/${file.hash}`, file.contents)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function initializeSnapshot() {
|
|
|
+ mockFlush()
|
|
|
+ mockLatestChunk()
|
|
|
+ mockBlobs(['main.tex', 'hello.txt'])
|
|
|
await snapshot.refresh()
|
|
|
- expect(fetchMock.done()).to.be.true
|
|
|
fetchMock.reset()
|
|
|
}
|
|
|
|
|
|
@@ -121,34 +171,11 @@ describe('ProjectSnapshot', function () {
|
|
|
})
|
|
|
})
|
|
|
|
|
|
- const changes = [
|
|
|
- {
|
|
|
- operations: [
|
|
|
- {
|
|
|
- pathname: 'hello.txt',
|
|
|
- textOperation: ['Quote: ', files['hello.txt'].contents.length],
|
|
|
- },
|
|
|
- {
|
|
|
- pathname: 'goodbye.txt',
|
|
|
- file: {
|
|
|
- hash: files['goodbye.txt'].hash,
|
|
|
- stringLength: files['goodbye.txt'].contents.length,
|
|
|
- },
|
|
|
- },
|
|
|
- ],
|
|
|
- timestamp: '2025-01-01T13:00:00.000Z',
|
|
|
- },
|
|
|
- ]
|
|
|
-
|
|
|
async function refreshSnapshot() {
|
|
|
- fetchMock.postOnce(`/project/${projectId}/flush`, 200, { repeat: 2 })
|
|
|
- fetchMock.getOnce(`/project/${projectId}/changes?since=1`, changes)
|
|
|
- fetchMock.getOnce(
|
|
|
- `/project/${projectId}/blob/${files['goodbye.txt'].hash}`,
|
|
|
- files['goodbye.txt'].contents
|
|
|
- )
|
|
|
+ mockFlush()
|
|
|
+ mockChanges()
|
|
|
+ mockBlobs(['goodbye.txt'])
|
|
|
await snapshot.refresh()
|
|
|
- expect(fetchMock.done()).to.be.true
|
|
|
fetchMock.reset()
|
|
|
}
|
|
|
|
|
|
@@ -170,7 +197,7 @@ describe('ProjectSnapshot', function () {
|
|
|
})
|
|
|
})
|
|
|
|
|
|
- describe('getDocCotents()', function () {
|
|
|
+ describe('getDocContents()', function () {
|
|
|
it('returns the up to date content', function () {
|
|
|
expect(snapshot.getDocContents('hello.txt')).to.equal(
|
|
|
`Quote: ${files['hello.txt'].contents}`
|
|
|
@@ -184,4 +211,177 @@ describe('ProjectSnapshot', function () {
|
|
|
})
|
|
|
})
|
|
|
})
|
|
|
+
|
|
|
+ describe('concurrency', function () {
|
|
|
+ afterEach(function () {
|
|
|
+ fetchMock.reset()
|
|
|
+ })
|
|
|
+
|
|
|
+ specify('two concurrent inits', async function () {
|
|
|
+ mockFlush({ repeat: 2 })
|
|
|
+ mockLatestChunk()
|
|
|
+ mockChanges()
|
|
|
+ mockBlobs()
|
|
|
+
|
|
|
+ await Promise.all([snapshot.refresh(), snapshot.refresh()])
|
|
|
+
|
|
|
+ // The first request initializes, the second request loads changes
|
|
|
+ expect(fetchMock.calls('flush')).to.have.length(2)
|
|
|
+ expect(fetchMock.calls('latest-chunk')).to.have.length(1)
|
|
|
+ expect(fetchMock.calls('changes-1')).to.have.length(1)
|
|
|
+ })
|
|
|
+
|
|
|
+ specify('three concurrent inits', async function () {
|
|
|
+ mockFlush({ repeat: 2 })
|
|
|
+ mockLatestChunk()
|
|
|
+ mockChanges()
|
|
|
+ mockBlobs()
|
|
|
+
|
|
|
+ await Promise.all([
|
|
|
+ snapshot.refresh(),
|
|
|
+ snapshot.refresh(),
|
|
|
+ snapshot.refresh(),
|
|
|
+ ])
|
|
|
+
|
|
|
+ // The first request initializes, the second and third are combined and
|
|
|
+ // load changes
|
|
|
+ expect(fetchMock.calls('flush')).to.have.length(2)
|
|
|
+ expect(fetchMock.calls('latest-chunk')).to.have.length(1)
|
|
|
+ expect(fetchMock.calls('changes-1')).to.have.length(1)
|
|
|
+ })
|
|
|
+
|
|
|
+ specify('two concurrent inits - first fails', async function () {
|
|
|
+ mockFlush({ repeat: 2, failOnCall: call => call === 1 })
|
|
|
+ mockLatestChunk()
|
|
|
+ mockBlobs()
|
|
|
+
|
|
|
+ const results = await Promise.allSettled([
|
|
|
+ snapshot.refresh(),
|
|
|
+ snapshot.refresh(),
|
|
|
+ ])
|
|
|
+
|
|
|
+ // The first init fails, but the second succeeds
|
|
|
+ expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
|
|
|
+ expect(fetchMock.calls('flush')).to.have.length(2)
|
|
|
+ expect(fetchMock.calls('latest-chunk')).to.have.length(1)
|
|
|
+ expect(fetchMock.calls('changes-1')).to.have.length(0)
|
|
|
+ })
|
|
|
+
|
|
|
+ specify('three concurrent inits - second fails', async function () {
|
|
|
+ mockFlush({ repeat: 4, failOnCall: call => call === 2 })
|
|
|
+ mockLatestChunk()
|
|
|
+ mockChanges()
|
|
|
+ mockBlobs()
|
|
|
+
|
|
|
+ const results = await Promise.allSettled([
|
|
|
+ snapshot.refresh(),
|
|
|
+ snapshot.refresh(),
|
|
|
+ snapshot.refresh(),
|
|
|
+ ])
|
|
|
+
|
|
|
+ // Another request afterwards
|
|
|
+ await snapshot.refresh()
|
|
|
+
|
|
|
+ // The first init succeeds, the two queued requests fail, the last request
|
|
|
+ // succeeds
|
|
|
+ expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
|
|
|
+ expect(fetchMock.calls('flush')).to.have.length(3)
|
|
|
+ expect(fetchMock.calls('latest-chunk')).to.have.length(1)
|
|
|
+ expect(fetchMock.calls('changes-1')).to.have.length(1)
|
|
|
+ expect(fetchMock.calls('changes-2')).to.have.length(0)
|
|
|
+ })
|
|
|
+
|
|
|
+ specify('two concurrent load changes', async function () {
|
|
|
+ mockFlush({ repeat: 3 })
|
|
|
+ mockLatestChunk()
|
|
|
+ mockChanges()
|
|
|
+ mockBlobs()
|
|
|
+
|
|
|
+ // Initialize
|
|
|
+ await snapshot.refresh()
|
|
|
+
|
|
|
+ // Two concurrent load changes
|
|
|
+ await Promise.all([snapshot.refresh(), snapshot.refresh()])
|
|
|
+
|
|
|
+ // One init, two load changes
|
|
|
+ expect(fetchMock.calls('flush')).to.have.length(3)
|
|
|
+ expect(fetchMock.calls('latest-chunk')).to.have.length(1)
|
|
|
+ expect(fetchMock.calls('changes-1')).to.have.length(1)
|
|
|
+ expect(fetchMock.calls('changes-2')).to.have.length(1)
|
|
|
+ })
|
|
|
+
|
|
|
+ specify('three concurrent load changes', async function () {
|
|
|
+ mockFlush({ repeat: 3 })
|
|
|
+ mockLatestChunk()
|
|
|
+ mockChanges()
|
|
|
+ mockBlobs()
|
|
|
+
|
|
|
+ // Initialize
|
|
|
+ await snapshot.refresh()
|
|
|
+
|
|
|
+ // Three concurrent load changes
|
|
|
+ await Promise.all([
|
|
|
+ snapshot.refresh(),
|
|
|
+ snapshot.refresh(),
|
|
|
+ snapshot.refresh(),
|
|
|
+ ])
|
|
|
+
|
|
|
+ // One init, two load changes (the two last are queued and combined)
|
|
|
+ expect(fetchMock.calls('flush')).to.have.length(3)
|
|
|
+ expect(fetchMock.calls('latest-chunk')).to.have.length(1)
|
|
|
+ expect(fetchMock.calls('changes-1')).to.have.length(1)
|
|
|
+ expect(fetchMock.calls('changes-2')).to.have.length(1)
|
|
|
+ })
|
|
|
+
|
|
|
+ specify('two concurrent load changes - first fails', async function () {
|
|
|
+ mockFlush({ repeat: 3, failOnCall: call => call === 2 })
|
|
|
+ mockLatestChunk()
|
|
|
+ mockChanges()
|
|
|
+ mockBlobs()
|
|
|
+
|
|
|
+ // Initialize
|
|
|
+ await snapshot.refresh()
|
|
|
+
|
|
|
+ // Two concurrent load changes
|
|
|
+ const results = await Promise.allSettled([
|
|
|
+ snapshot.refresh(),
|
|
|
+ snapshot.refresh(),
|
|
|
+ ])
|
|
|
+
|
|
|
+ // One init, one load changes fails, the second succeeds
|
|
|
+ expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
|
|
|
+ expect(fetchMock.calls('flush')).to.have.length(3)
|
|
|
+ expect(fetchMock.calls('latest-chunk')).to.have.length(1)
|
|
|
+ expect(fetchMock.calls('changes-1')).to.have.length(1)
|
|
|
+ expect(fetchMock.calls('changes-2')).to.have.length(0)
|
|
|
+ })
|
|
|
+
|
|
|
+ specify('three concurrent load changes - second fails', async function () {
|
|
|
+ mockFlush({ repeat: 4, failOnCall: call => call === 3 })
|
|
|
+ mockLatestChunk()
|
|
|
+ mockChanges()
|
|
|
+ mockBlobs()
|
|
|
+
|
|
|
+ // Initialize
|
|
|
+ await snapshot.refresh()
|
|
|
+
|
|
|
+ // Two concurrent load changes
|
|
|
+ const results = await Promise.allSettled([
|
|
|
+ snapshot.refresh(),
|
|
|
+ snapshot.refresh(),
|
|
|
+ snapshot.refresh(),
|
|
|
+ ])
|
|
|
+
|
|
|
+ // Another request afterwards
|
|
|
+ await snapshot.refresh()
|
|
|
+
|
|
|
+ // One init, one load changes succeeds, the second and third are combined
|
|
|
+ // and fail, the last request succeeds
|
|
|
+ expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
|
|
|
+ expect(fetchMock.calls('flush')).to.have.length(4)
|
|
|
+ expect(fetchMock.calls('latest-chunk')).to.have.length(1)
|
|
|
+ expect(fetchMock.calls('changes-1')).to.have.length(1)
|
|
|
+ expect(fetchMock.calls('changes-2')).to.have.length(1)
|
|
|
+ })
|
|
|
+ })
|
|
|
})
|