388 lines
11 KiB
TypeScript
388 lines
11 KiB
TypeScript
import { expect } from 'chai'
|
|
import fetchMock from 'fetch-mock'
|
|
import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
|
|
|
|
describe('ProjectSnapshot', function () {
|
|
let snapshot: ProjectSnapshot
|
|
const projectId = 'project-id'
|
|
|
|
beforeEach(function () {
|
|
snapshot = new ProjectSnapshot(projectId)
|
|
})
|
|
|
|
describe('before initialization', function () {
|
|
describe('getDocPaths()', function () {
|
|
it('returns an empty string', function () {
|
|
expect(snapshot.getDocPaths()).to.deep.equal([])
|
|
})
|
|
})
|
|
|
|
describe('getDocContents()', function () {
|
|
it('returns null', function () {
|
|
expect(snapshot.getDocContents('main.tex')).to.be.null
|
|
})
|
|
})
|
|
})
|
|
|
|
const files = {
|
|
'main.tex': {
|
|
contents: '\\documentclass{article}\netc.',
|
|
hash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
|
},
|
|
'hello.txt': {
|
|
contents: 'Hello history!',
|
|
hash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
},
|
|
'goodbye.txt': {
|
|
contents: "We're done here",
|
|
hash: 'dddddddddddddddddddddddddddddddddddddddd',
|
|
},
|
|
}
|
|
|
|
const chunk = {
|
|
history: {
|
|
snapshot: {
|
|
files: {},
|
|
},
|
|
changes: [
|
|
{
|
|
operations: [
|
|
{
|
|
pathname: 'hello.txt',
|
|
file: {
|
|
hash: files['hello.txt'].hash,
|
|
stringLength: files['hello.txt'].contents.length,
|
|
},
|
|
},
|
|
{
|
|
pathname: 'main.tex',
|
|
file: {
|
|
hash: files['main.tex'].hash,
|
|
stringLength: files['main.tex'].contents.length,
|
|
},
|
|
},
|
|
{
|
|
pathname: 'frog.jpg',
|
|
file: {
|
|
hash: 'cccccccccccccccccccccccccccccccccccccccc',
|
|
byteLength: 97080,
|
|
},
|
|
},
|
|
],
|
|
timestamp: '2025-01-01T12:00:00.000Z',
|
|
},
|
|
],
|
|
},
|
|
startVersion: 0,
|
|
}
|
|
|
|
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}/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()
|
|
fetchMock.removeRoutes().clearHistory()
|
|
}
|
|
|
|
describe('after initialization', function () {
|
|
beforeEach(initializeSnapshot)
|
|
|
|
describe('getDocPaths()', function () {
|
|
it('returns the editable docs', function () {
|
|
expect(snapshot.getDocPaths()).to.have.members([
|
|
'main.tex',
|
|
'hello.txt',
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('getDocContents()', function () {
|
|
it('returns the doc contents', function () {
|
|
expect(snapshot.getDocContents('main.tex')).to.equal(
|
|
files['main.tex'].contents
|
|
)
|
|
})
|
|
|
|
it('returns null for binary files', function () {
|
|
expect(snapshot.getDocContents('frog.jpg')).to.be.null
|
|
})
|
|
|
|
it('returns null for inexistent files', function () {
|
|
expect(snapshot.getDocContents('does-not-exist.txt')).to.be.null
|
|
})
|
|
})
|
|
})
|
|
|
|
async function refreshSnapshot() {
|
|
mockFlush()
|
|
mockChanges()
|
|
mockBlobs(['goodbye.txt'])
|
|
await snapshot.refresh()
|
|
fetchMock.removeRoutes().clearHistory()
|
|
}
|
|
|
|
describe('after refresh', function () {
|
|
beforeEach(initializeSnapshot)
|
|
beforeEach(refreshSnapshot)
|
|
|
|
afterEach(function () {
|
|
fetchMock.removeRoutes().clearHistory()
|
|
})
|
|
|
|
describe('getDocPaths()', function () {
|
|
it('returns the editable docs', function () {
|
|
expect(snapshot.getDocPaths()).to.have.members([
|
|
'main.tex',
|
|
'hello.txt',
|
|
'goodbye.txt',
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('getDocContents()', function () {
|
|
it('returns the up to date content', function () {
|
|
expect(snapshot.getDocContents('hello.txt')).to.equal(
|
|
`Quote: ${files['hello.txt'].contents}`
|
|
)
|
|
})
|
|
|
|
it('returns contents of new files', function () {
|
|
expect(snapshot.getDocContents('goodbye.txt')).to.equal(
|
|
files['goodbye.txt'].contents
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('concurrency', function () {
|
|
afterEach(function () {
|
|
fetchMock.removeRoutes().clearHistory()
|
|
})
|
|
|
|
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.callHistory.calls('flush')).to.have.length(2)
|
|
expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
|
|
expect(fetchMock.callHistory.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.callHistory.calls('flush')).to.have.length(2)
|
|
expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
|
|
expect(fetchMock.callHistory.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.callHistory.calls('flush')).to.have.length(2)
|
|
expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
|
|
expect(fetchMock.callHistory.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.callHistory.calls('flush')).to.have.length(3)
|
|
expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
|
|
expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
|
|
expect(fetchMock.callHistory.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.callHistory.calls('flush')).to.have.length(3)
|
|
expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
|
|
expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
|
|
expect(fetchMock.callHistory.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.callHistory.calls('flush')).to.have.length(3)
|
|
expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
|
|
expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
|
|
expect(fetchMock.callHistory.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.callHistory.calls('flush')).to.have.length(3)
|
|
expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
|
|
expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
|
|
expect(fetchMock.callHistory.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.callHistory.calls('flush')).to.have.length(4)
|
|
expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
|
|
expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
|
|
expect(fetchMock.callHistory.calls('changes-2')).to.have.length(1)
|
|
})
|
|
})
|
|
})
|