first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,208 @@
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import {
deleteJSON,
FetchError,
getUserFacingMessage,
getJSON,
postJSON,
putJSON,
} from '../../../frontend/js/infrastructure/fetch-json'
describe('fetchJSON', function () {
before(function () {
fetchMock.removeRoutes().clearHistory()
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
const headers = {
Accept: 'application/json',
'Content-Type': 'application/json',
}
it('handles GET requests', function () {
fetchMock.once(
{ method: 'GET', url: '/test', headers },
{ status: 200, body: { result: 'success' } }
)
return expect(getJSON('/test')).to.eventually.deep.equal({
result: 'success',
})
})
it('handles 4xx responses', function () {
fetchMock.get('/test', {
status: 400,
body: { message: 'The request was invalid' },
})
return expect(getJSON('/test'))
.to.eventually.be.rejectedWith('Bad Request')
.and.be.an.instanceOf(FetchError)
.to.nested.include({
message: 'Bad Request',
'data.message': 'The request was invalid',
'response.status': 400,
'info.statusCode': 400,
})
})
it('handles 5xx responses', async function () {
fetchMock.get('/test', { status: 500 })
return expect(getJSON('/test'))
.to.eventually.be.rejectedWith('Internal Server Error')
.and.be.an.instanceOf(FetchError)
.to.nested.include({
'response.status': 500,
'info.statusCode': 500,
})
})
it('handles JSON error responses', async function () {
fetchMock.get('/test', {
status: 500,
headers: {
'Content-Type': 'application/json',
},
body: { message: 'lorem ipsum' },
})
return expect(getJSON('/test'))
.to.eventually.be.rejectedWith('Internal Server Error')
.and.be.an.instanceOf(FetchError)
.to.nested.include({
'data.message': 'lorem ipsum',
})
})
it('handles text error responses', async function () {
fetchMock.get('/test', {
status: 500,
headers: {
'Content-Type': 'text/plain',
},
body: 'lorem ipsum',
})
return expect(getJSON('/test'))
.to.eventually.be.rejectedWith('Internal Server Error')
.and.be.an.instanceOf(FetchError)
.to.nested.include({
'data.message': 'lorem ipsum',
})
})
it('handles text error responses sent as HTML', async function () {
fetchMock.get('/test', {
status: 500,
headers: {
'Content-Type': 'text/html',
},
body: 'lorem ipsum',
})
return expect(getJSON('/test'))
.to.eventually.be.rejectedWith('Internal Server Error')
.and.be.an.instanceOf(FetchError)
.to.nested.include({
'data.message': 'lorem ipsum',
})
})
it('handles (ignores) HTML error responses sent as HTML', async function () {
fetchMock.get('/test', {
status: 500,
headers: {
'Content-Type': 'text/html',
},
body: '<!doctype html><html lang="en"><body><p>lorem ipsum</p></body></html>',
})
const promise = getJSON('/test')
expect(promise)
.to.eventually.be.rejectedWith('Internal Server Error')
.and.be.an.instanceOf(FetchError)
try {
await promise
} catch (error) {
expect(error.data).to.eql({})
}
})
it('handles 5xx responses without a status message', async function () {
fetchMock.get('/test', { status: 599 })
return expect(getJSON('/test'))
.to.eventually.be.rejectedWith('Unexpected Error: 599')
.and.be.an.instanceOf(FetchError)
.to.nested.include({
'response.status': 599,
'info.statusCode': 599,
message: 'Unexpected Error: 599',
})
})
it('handles POST requests', function () {
const body = { example: true }
fetchMock.once(
{ method: 'POST', url: '/test', headers, body },
{ status: 200, body: { result: 'success' } }
)
return expect(postJSON('/test', { body })).to.eventually.deep.equal({
result: 'success',
})
})
it('handles PUT requests', function () {
const body = { example: true }
fetchMock.once(
{ method: 'PUT', url: '/test', headers, body },
{ status: 200, body: { result: 'success' } }
)
return expect(putJSON('/test', { body })).to.eventually.deep.equal({
result: 'success',
})
})
it('handles DELETE requests', function () {
fetchMock.once({ method: 'DELETE', url: '/test', headers }, { status: 204 })
return expect(deleteJSON('/test')).to.eventually.deep.equal({})
})
describe('getUserFacingMessage()', function () {
it('returns the error facing message for FetchError instances', function () {
const error = new FetchError(
'403 error',
'http:/example.com',
{},
{ status: 403 }
)
expect(getUserFacingMessage(error)).to.equal(
'Session error. Please check you have cookies enabled. If the problem persists, try clearing your cache and cookies.'
)
})
it('returns `message` for Error instances different than FetchError', function () {
const error = new Error('403 error')
expect(getUserFacingMessage(error)).to.equal('403 error')
})
it('returns `undefined` for non-Error instances', function () {
expect(getUserFacingMessage(undefined)).to.be.undefined
expect(getUserFacingMessage(null)).to.be.undefined
expect(getUserFacingMessage('error')).to.be.undefined
})
})
})

View File

@@ -0,0 +1,147 @@
import { Trans, useTranslation } from 'react-i18next'
describe('i18n', function () {
describe('t', function () {
it('translates a plain string', function () {
const Test = () => {
const { t } = useTranslation()
return <div>{t('accept_change')}</div>
}
cy.mount(<Test />)
cy.findByText('Accept change')
})
it('uses defaultValues', function () {
const Test = () => {
const { t } = useTranslation()
return <div>{t('welcome_to_sl')}</div>
}
cy.mount(<Test />)
cy.findByText('Welcome to Overleaf')
})
it('uses values', function () {
const Test = () => {
const { t } = useTranslation()
return <div>{t('sort_by_x', { x: 'name' })}</div>
}
cy.mount(<Test />)
cy.findByText('Sort by name')
})
})
describe('Trans', function () {
it('uses values', function () {
const Test = () => {
return (
<div>
<Trans
i18nKey="sort_by_x"
values={{ x: 'name' }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</div>
)
}
cy.mount(<Test />)
cy.findByText('Sort by name')
})
it('uses an object of components', function () {
const Test = () => {
return (
<div data-testid="container">
<Trans
i18nKey="in_order_to_match_institutional_metadata_associated"
components={{ b: <b /> }}
values={{ email: 'test@example.com' }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</div>
)
}
cy.mount(<Test />)
cy.findByTestId('container')
.should(
'have.text',
'In order to match your institutional metadata, your account is associated with the email test@example.com.'
)
.find('b')
.should('have.length', 1)
.should('have.text', 'test@example.com')
})
it('uses an array of components', function () {
const Test = () => {
return (
<div data-testid="container">
<Trans
i18nKey="are_you_still_at"
components={[<b />]} // eslint-disable-line react/jsx-key
values={{ institutionName: 'Test' }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</div>
)
}
cy.mount(<Test />)
cy.findByTestId('container')
.should('have.text', 'Are you still at Test?')
.find('b')
.should('have.length', 1)
.should('have.text', 'Test')
})
it('escapes special characters', function () {
const Test = () => {
return (
<div data-testid="container">
<Trans
i18nKey="are_you_still_at"
components={[<b />]} // eslint-disable-line react/jsx-key
values={{ institutionName: "T&e's<code>t</code>ing" }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</div>
)
}
cy.mount(<Test />)
cy.findByTestId('container')
.should('have.text', "Are you still at T&e's<code>t</code>ing?")
.find('b')
.should('have.length', 1)
.should('have.text', "T&e's<code>t</code>ing")
})
it('does not convert markup in values to components', function () {
const Test = () => {
return (
<div data-testid="container">
<Trans
i18nKey="are_you_still_at"
components={[<b />]} // eslint-disable-line react/jsx-key
values={{
institutionName: "<i>T</i>&<b>e</b>'s<code>t</code>ing",
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</div>
)
}
cy.mount(<Test />)
cy.findByTestId('container')
.should(
'have.text',
"Are you still at <i>T</i>&<b>e</b>'s<code>t</code>ing?"
)
.find('b')
.should('have.length', 1)
.should('have.text', "<i>T</i>&<b>e</b>'s<code>t</code>ing")
})
})
})

View File

@@ -0,0 +1,88 @@
import { expect } from 'chai'
import sinon from 'sinon'
import customLocalStorage from '@/infrastructure/local-storage'
import { debugConsole } from '@/utils/debugging'
describe('localStorage', function () {
let originalLocalStorage
before(function () {
originalLocalStorage = global.localStorage
})
after(function () {
Object.defineProperty(global, 'localStorage', {
value: originalLocalStorage,
})
})
let spyOnDebugConsoleError
beforeEach(function () {
Object.defineProperty(global, 'localStorage', {
value: {
getItem: sinon.stub().returns(null),
setItem: sinon.stub(),
clear: sinon.stub(),
removeItem: sinon.stub(),
},
})
spyOnDebugConsoleError = sinon.spy(debugConsole, 'error')
})
afterEach(function () {
spyOnDebugConsoleError.restore()
Object.defineProperty(global, 'localStorage', { value: undefined })
})
it('getItem', function () {
expect(customLocalStorage.getItem('foo')).to.be.null
global.localStorage.getItem.returns('false')
expect(customLocalStorage.getItem('foo')).to.equal(false)
global.localStorage.getItem.returns('{"foo":"bar"}')
expect(customLocalStorage.getItem('foo')).to.deep.equal({ foo: 'bar' })
global.localStorage.getItem.throws(new Error('Nope'))
expect(customLocalStorage.getItem('foo')).to.be.null
expect(debugConsole.error).to.be.calledOnce
})
it('setItem', function () {
customLocalStorage.setItem('foo', 'bar')
expect(global.localStorage.setItem).to.be.calledOnceWith('foo', '"bar"')
global.localStorage.setItem.reset()
customLocalStorage.setItem('foo', true)
expect(global.localStorage.setItem).to.be.calledOnceWith('foo', 'true')
global.localStorage.setItem.reset()
customLocalStorage.setItem('foo', { bar: 1 })
expect(global.localStorage.setItem).to.be.calledOnceWith('foo', '{"bar":1}')
global.localStorage.setItem.reset()
global.localStorage.setItem.throws(new Error('Nope'))
expect(customLocalStorage.setItem('foo', 'bar')).to.be.null
expect(debugConsole.error).to.be.calledOnce
})
it('clear', function () {
customLocalStorage.clear()
expect(global.localStorage.clear).to.be.calledOnce
global.localStorage.clear.throws(new Error('Nope'))
expect(customLocalStorage.clear()).to.be.null
expect(debugConsole.error).to.be.calledOnce
})
it('removeItem', function () {
customLocalStorage.removeItem('foo')
expect(global.localStorage.removeItem).to.be.calledOnceWith('foo')
global.localStorage.removeItem.reset()
global.localStorage.removeItem.throws(new Error('Nope'))
expect(customLocalStorage.removeItem('foo')).to.be.null
expect(debugConsole.error).to.be.calledOnce
})
})

View File

@@ -0,0 +1,387 @@
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)
})
})
})