import request from 'supertest' import { generateSimpleViteManifest, mockApp, mockConfig, mockFetch, mockRedis } from './util.js' import fs from 'fs' import { expect } from 'chai' import * as td from 'testdouble' import RedisMock from 'ioredis-mock' import sinon from 'sinon' import zlib from 'node:zlib' const image = fs.readFileSync('./spec/media/image.png') const imageStat = fs.statSync('./spec/media/image.png') const sandbox = sinon.createSandbox() describe('File caching service', function () { let app let redis beforeEach(async function () { let count = 0 mockConfig({ urls: ['http://ui-server/'] }) redis = mockRedis() mockFetch({ 'http://ui-server': { '/manifest.json': generateSimpleViteManifest({ 'example.js': { imports: ['test.txt'] }, 'test.txt': { }, 'main.css': {}, 'index.html': { file: 'index.html.js', isEntry: true, imports: ['example.js'], css: ['main.css'] }, 'image.png': {} }), '/example.js': () => new Response('this is example', { headers: { 'content-type': 'application/javascript' } }), '/test.txt': () => new Response('this is test', { headers: { 'content-type': 'text/plain' } }), '/index.html.js': () => new Response('this is index.html.js', { headers: { 'content-type': 'application/javascript' } }), '/index.html': () => new Response('<html><head></head><body>it\'s me</body></html>', { headers: { 'content-type': 'text/html' } }), '/main.css': () => new Response('.foo { color: #000; }', { headers: { 'content-type': 'text/css' } }), '/favicon.ico': 'not really a favicon, though', '/test.svg': () => { if (count > 0) { return new Response(null, { status: 404 }) } count++ return new Response('<svg></svg>', { headers: { 'content-type': 'image/svg' } }) }, '/image.png': () => { return new Response(image, { headers: { 'Content-Type': 'image/png', 'Content-Length': imageStat.size.toString() } }) } } }) app = await mockApp() }) afterEach(async function () { await new RedisMock().flushdb() td.reset() }) it('serves files defined in manifest.json file', async function () { const response = await request(app.server).get('/example.js') expect(response.statusCode).to.equal(200) expect(response.headers['content-type']).to.equal('application/javascript') expect(response.text).to.equal('this is example') // expect(response.headers['content-security-policy']).to.contain('sha256-NzZhMTE2Njc2YTgyNTZmZTdlZGVjZDU3YTNmYzRjNmM1OWZkMTI2NjRkYzZmMWM3YTkwMGU3ZTdhNDlhZmVlMwo=') const response2 = await request(app.server).get('/test.txt') expect(response2.statusCode).to.equal(200) expect(response2.headers['content-type']).to.equal('text/plain') expect(response2.text).to.equal('this is test') }) it('serves css files', async function () { const response = await request(app.server).get('/main.css') expect(response.statusCode).to.equal(200) expect(response.headers['content-type']).to.equal('text/css') // expect(response.headers['content-security-policy']).to.contain('sha256-YjRiYWRlYTVhYmM5ZTZkNjE2ZGM4YjcwZWRlNzUxMmU0YjgxY2UxMWExOTI2ZjM1NzM1M2Y2MWJjNmUwMmZjMwo=') }) it('serves / as index.html', async function () { const response = await request(app.server).get('/') expect(response.statusCode).to.equal(200) expect(response.headers['content-type']).to.equal('text/html') expect(response.text).to.equal('<html><head></head><body>it\'s me</body></html>') }) it('directly fetches files not referenced in manifest.json files from the upstream servers', async function () { const response = await request(app.server).get('/favicon.ico') expect(response.statusCode).to.equal(200) expect(response.text).to.equal('not really a favicon, though') }) it('returns 404 if file can not be resolved', async function () { const response = await request(app.server).get('/unknown-file.txt') expect(response.statusCode).to.equal(404) }) it('serves binary files', async function () { const response = await request(app.server).get('/image.png') expect(response.statusCode).to.equal(200) expect(response.body.length === imageStat.size) expect(response.body).to.deep.equal(image) }) it('only fetches files once', async function () { let spy mockFetch({ 'http://ui-server': { '/manifest.json': generateSimpleViteManifest({}), '/example.js': spy = sandbox.spy(() => { return new Response('this is example', { headers: { 'content-type': 'application/javascript' } }) }) } }) app = await mockApp() expect(spy.callCount).to.equal(0) let response = await request(app.server).get('/example.js') expect(response.statusCode).to.equal(200) expect(spy.callCount).to.equal(1) response = await request(app.server).get('/example.js') expect(response.statusCode).to.equal(200) expect(spy.callCount).to.equal(1) }) it('only fetches files once, but deliver from local cache', async function () { let spy mockFetch({ 'http://ui-server': { '/manifest.json': generateSimpleViteManifest({}), '/example.js': spy = sandbox.spy(() => { return new Response('this is example', { headers: { 'content-type': 'application/javascript' } }) }) } }) app = await mockApp() expect(spy.callCount).to.equal(0) let response = await request(app.server).get('/example.js') expect(response.statusCode).to.equal(200) expect(spy.callCount).to.equal(1) // delete file from redis await redis.client.del(`${response.headers.version}:/example.js:body`) await redis.client.del(`${response.headers.version}:/example.js:meta`) // and fetch once more response = await request(app.server).get('/example.js') expect(response.statusCode).to.equal(200) expect(spy.callCount).to.equal(1) }) it('delivers binary files from cache', async function () { let spy mockFetch({ 'http://ui-server': { '/manifest.json': generateSimpleViteManifest({}), '/image.png': spy = sandbox.spy(() => { return new Response(image, { headers: { 'Content-Type': 'image/png', 'Content-Length': imageStat.size.toString() } }) }) } }) app = await mockApp() expect(spy.callCount).to.equal(0) let response = await request(app.server).get('/image.png') expect(response.statusCode).to.equal(200) expect(response.body).to.deep.equal(image) expect(spy.callCount).to.equal(1) response = await request(app.server).get('/image.png') expect(response.statusCode).to.equal(200) expect(response.body).to.deep.equal(image) expect(spy.callCount).to.equal(1) }) it('a file is not cached again, if loaded from cache', async function () { const spy = sandbox.spy(redis.client, 'set') let response = await request(app.server).get('/example.js') expect(response.statusCode).to.equal(200) // called 4 times. // once for manifests // once for dependencies // two times for for example.js (meta and body) expect(spy.callCount).to.equal(4) response = await request(app.server).get('/example.js') expect(response.statusCode).to.equal(200) // should still be called 4 times, because everything is in cache expect(spy.callCount).to.equal(4) }) it('requests known file only from one server', async function () { let spy1, spy2 mockConfig({ urls: ['http://ui-server1/', 'http://ui-server2/'] }) // we have example.js in both files. the first one will be overwritten and therefore not be called mockFetch({ 'http://ui-server1': { '/manifest.json': generateSimpleViteManifest({ 'example.js': { } }), '/example.js': spy1 = sandbox.spy(() => { return new Response('example', { headers: { 'content-type': 'text/plain' } }) }) }, 'http://ui-server2': { '/manifest.json': generateSimpleViteManifest({ 'example.js': { } }), '/example.js': spy2 = sandbox.spy(() => { return new Response('example', { headers: { 'content-type': 'text/plain' } }) }) } }) app = await mockApp() const response = await request(app.server).get('/example.js') expect(response.statusCode).to.equal(200) expect(spy1.callCount).to.equal(0) expect(spy2.callCount).to.equal(1) }) it('serves cached files with version', async function () { // we have example.js in both files. the first one will be overwritten and therefore not be called mockFetch({ 'http://ui-server': { '/manifest.json': generateSimpleViteManifest({ 'example.js': { } }), '/example.js': td.when(td.func()(td.matchers.anything())).thenReturn( new Response('first', { headers: { 'content-type': 'text/plain' } }), new Response('second', { headers: { 'content-type': 'text/plain' } }) ) } }) app = await mockApp() const response1 = await request(app.server).get('/example.js').set('version', '1234') expect(response1.statusCode).to.equal(200) expect(response1.text).to.equal('first') const response2 = await request(app.server).get('/example.js') expect(response2.statusCode).to.equal(200) expect(response2.text).to.equal('second') const latestVersion = response2.headers['latest-version'] const response3 = await request(app.server).get('/example.js').set('version', '1234') expect(response3.statusCode).to.equal(200) expect(response3.text).to.equal('first') const response4 = await request(app.server).get('/example.js') expect(response4.statusCode).to.equal(200) expect(response4.text).to.equal('second') const response5 = await request(app.server).get('/example.js').set('version', latestVersion) expect(response5.statusCode).to.equal(200) expect(response5.text).to.equal('second') }) it('only fetches files once, even when requested simultanously', async function () { let spy mockFetch({ 'http://ui-server': { '/manifest.json': generateSimpleViteManifest({}), '/example.js': spy = sandbox.spy(() => { return new Response('this is example', { headers: { 'content-type': 'application/javascript' } }) }) } }) app = await mockApp() expect(spy.callCount).to.equal(0) const [res1, res2] = await Promise.all([ request(app.server).get('/example.js'), request(app.server).get('/example.js') ]) expect(res1.statusCode).to.equal(200) expect(res2.statusCode).to.equal(200) expect(spy.callCount).to.equal(1) }) it('only fetches manifests once, even when requested simultanously', async function () { let spy mockFetch({ 'http://ui-server': { '/manifest.json': spy = sandbox.spy(async () => { await new Promise(resolve => setTimeout(resolve, 10)) return new Response(JSON.stringify(generateSimpleViteManifest({})), { headers: { 'content-type': 'application/json' } }) }), '/example.js': () => new Response('this is example', { headers: { 'content-type': 'application/javascript' } }) } }) app = await mockApp() expect(spy.callCount).to.equal(0) const [res1, res2] = await Promise.all([ request(app.server).get('/manifests'), request(app.server).get('/example.js') ]) expect(res1.statusCode).to.equal(200) expect(res2.statusCode).to.equal(200) expect(spy.callCount).to.equal(1) }) describe('redis request latency', function () { beforeEach(function () { // overwrite redis.get to simulate network latency const get = redis.client.get redis.client.get = function () { return new Promise(resolve => { setTimeout(async () => { resolve(await get.apply(redis.client, arguments)) }, 20) }) } }) it('only requests files once, even though the response takes some time', async function () { let spy mockFetch({ 'http://ui-server': { '/manifest.json': generateSimpleViteManifest({}), '/example.js': spy = sandbox.spy(() => { return new Response('this is example', { headers: { 'content-type': 'application/javascript' } }) }) } }) app = await mockApp() expect(spy.callCount).to.equal(0) const [res1, res2] = await Promise.all([ request(app.server).get('/example.js'), request(app.server).get('/example.js') ]) expect(res1.statusCode).to.equal(200) expect(res2.statusCode).to.equal(200) expect(spy.callCount).to.equal(1) }) }) it('serves files as gzip', async function () { mockFetch({ 'http://ui-server': { '/manifest.json': generateSimpleViteManifest({}), '/large.js': () => new Response([...new Array(2500)].join(' '), { headers: { 'content-type': 'application/javascript' } }) } }) app = await mockApp() const response = await request(app.server).get('/large.js') expect(response.statusCode).to.equal(200) expect(response.headers['content-encoding']).to.equal('gzip') // check for files in redis expect(zlib.gunzipSync(await redis.client.getBuffer('ui-middleware:554855300:/large.js:body')).toString()).to.equal(response.text) }) it('does not serve small files with gzip', async function () { mockFetch({ 'http://ui-server': { '/manifest.json': generateSimpleViteManifest({}), '/small.js': () => new Response('small', { headers: { 'content-type': 'application/javascript' } }) } }) app = await mockApp() const response = await request(app.server).get('/small.js') expect(response.statusCode).to.equal(200) expect(response.headers).to.not.have.property('content-encoding') // check for files in redis expect((await redis.client.getBuffer('ui-middleware:554855300:/small.js:body')).toString()).to.equal(response.text) }) it('does not serve other mime types with gzip', async function () { mockFetch({ 'http://ui-server': { '/manifest.json': generateSimpleViteManifest({}), '/file.mp3': () => new Response('123', { headers: { 'content-type': 'audio/mpeg' } }) } }) app = await mockApp() const response = await request(app.server).get('/file.mp3') expect(response.statusCode).to.equal(200) expect(response.headers).to.not.have.property('content-encoding') // check for files in redis expect(await redis.client.getBuffer('ui-middleware:554855300:/file.mp3:body')).to.deep.equal(response.body) }) })