193 lines
7.7 KiB
TypeScript
193 lines
7.7 KiB
TypeScript
/**
|
|
* E2E integration test — SPA static serving (Phase P5 cutover, #1444; tests
|
|
* added in P6, #1445, review follow-up SF1 on PR #1453).
|
|
*
|
|
* Boots a real Nest+Fastify app the way main.ts does (mountSpaStatic after the
|
|
* controllers) against a fixture dist directory, and pins the serving
|
|
* contract:
|
|
*
|
|
* 1. `/` and client-side deep links fall back to index.html.
|
|
* 2. Declared API routes win over the catch-all.
|
|
* 3. Unknown backend paths (/api, /mcp, /socket.io) are JSON 404s, never the
|
|
* SPA page — including with a query string (`/api?x=1`).
|
|
* 4. Static files are served exactly; hashed /assets/ files get immutable
|
|
* cache headers, everything else revalidates (max-age=0), and a missing
|
|
* /assets/ file is a 404 — never the SPA fallback.
|
|
* 5. WEB_DIST_DIR unset disables SPA serving entirely.
|
|
* 6. WEB_DIST_DIR pointing at a directory without index.html fails at boot.
|
|
*/
|
|
|
|
import 'reflect-metadata';
|
|
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
|
import { Test } from '@nestjs/testing';
|
|
import { Controller, Get, type INestApplication } from '@nestjs/common';
|
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
import request from 'supertest';
|
|
import { mountSpaStatic } from './serve-spa.js';
|
|
|
|
const INDEX_HTML = '<!doctype html><html><body>mosaic spa fixture</body></html>\n';
|
|
const ASSET_JS = 'console.log("hashed asset");\n';
|
|
|
|
@Controller('api/spa-test')
|
|
class SpaTestController {
|
|
@Get('ping')
|
|
ping(): { ok: boolean } {
|
|
return { ok: true };
|
|
}
|
|
}
|
|
|
|
async function createApp(): Promise<INestApplication> {
|
|
const moduleRef = await Test.createTestingModule({
|
|
controllers: [SpaTestController],
|
|
}).compile();
|
|
|
|
const app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
|
await app.init();
|
|
// Mirror main.ts ordering: SPA mounting happens after the app (and its
|
|
// controllers) exist, before listen.
|
|
await mountSpaStatic(app as NestFastifyApplication);
|
|
await (app as NestFastifyApplication).getHttpAdapter().getInstance().ready();
|
|
return app;
|
|
}
|
|
|
|
describe('SPA static serving — fixture dist dir', () => {
|
|
let app: INestApplication;
|
|
let distDir: string;
|
|
let previousWebDistDir: string | undefined;
|
|
|
|
beforeAll(async () => {
|
|
distDir = await mkdtemp(path.join(tmpdir(), 'serve-spa-fixture-'));
|
|
await writeFile(path.join(distDir, 'index.html'), INDEX_HTML);
|
|
await writeFile(path.join(distDir, 'favicon.svg'), '<svg></svg>\n');
|
|
await mkdir(path.join(distDir, 'assets'), { recursive: true });
|
|
await writeFile(path.join(distDir, 'assets', 'app-abc123.js'), ASSET_JS);
|
|
|
|
previousWebDistDir = process.env['WEB_DIST_DIR'];
|
|
process.env['WEB_DIST_DIR'] = distDir;
|
|
app = await createApp();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (previousWebDistDir === undefined) {
|
|
delete process.env['WEB_DIST_DIR'];
|
|
} else {
|
|
process.env['WEB_DIST_DIR'] = previousWebDistDir;
|
|
}
|
|
await app.close();
|
|
await rm(distDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('serves index.html at /', async () => {
|
|
const res = await request(app.getHttpServer()).get('/');
|
|
expect(res.status).toBe(200);
|
|
expect(res.text).toBe(INDEX_HTML);
|
|
expect(res.headers['content-type']).toContain('text/html');
|
|
});
|
|
|
|
it('falls back to index.html for client-side deep links', async () => {
|
|
for (const deepLink of ['/chat', '/projects/42', '/settings']) {
|
|
const res = await request(app.getHttpServer()).get(deepLink);
|
|
expect(res.status, deepLink).toBe(200);
|
|
expect(res.text, deepLink).toBe(INDEX_HTML);
|
|
}
|
|
});
|
|
|
|
it('declared API routes win over the SPA catch-all', async () => {
|
|
const res = await request(app.getHttpServer()).get('/api/spa-test/ping');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ ok: true });
|
|
});
|
|
|
|
it('unknown backend paths are JSON 404s, never the SPA page', async () => {
|
|
for (const backendPath of ['/api/nope', '/api', '/mcp/nope', '/socket.io/nope']) {
|
|
const res = await request(app.getHttpServer()).get(backendPath);
|
|
expect(res.status, backendPath).toBe(404);
|
|
expect(res.headers['content-type'], backendPath).toContain('application/json');
|
|
expect(res.body, backendPath).toMatchObject({ error: 'Not Found', statusCode: 404 });
|
|
}
|
|
});
|
|
|
|
it('a backend path with a query string is still a backend 404 (/api?x=1)', async () => {
|
|
const res = await request(app.getHttpServer()).get('/api?x=1');
|
|
expect(res.status).toBe(404);
|
|
expect(res.headers['content-type']).toContain('application/json');
|
|
});
|
|
|
|
it('serves static files exactly', async () => {
|
|
const res = await request(app.getHttpServer()).get('/favicon.svg');
|
|
expect(res.status).toBe(200);
|
|
// supertest buffers image/svg+xml as a Buffer body, not res.text.
|
|
const body = res.text || (res.body as Buffer).toString('utf8');
|
|
expect(body).toBe('<svg></svg>\n');
|
|
});
|
|
|
|
it('hashed /assets/ files get immutable cache headers', async () => {
|
|
const res = await request(app.getHttpServer()).get('/assets/app-abc123.js');
|
|
expect(res.status).toBe(200);
|
|
expect(res.text).toBe(ASSET_JS);
|
|
expect(res.headers['cache-control']).toBe('public, max-age=31536000, immutable');
|
|
});
|
|
|
|
it('missing /assets/ files are 404s, never the SPA page with an immutable header', async () => {
|
|
// The exact request a browser with a stale index.html makes after a
|
|
// deploy: the old hashed filename. Serving index.html here would poison
|
|
// caches with a year-long immutable entry whose body is HTML.
|
|
for (const missingAsset of ['/assets/app-old999.js', '/assets/app-old999.js?v=1']) {
|
|
const res = await request(app.getHttpServer()).get(missingAsset);
|
|
expect(res.status, missingAsset).toBe(404);
|
|
expect(res.text, missingAsset).not.toContain('mosaic spa fixture');
|
|
// The 404 carries no cache-control at all; ?? '' keeps the assertion valid.
|
|
expect(res.headers['cache-control'] ?? '', missingAsset).not.toContain('immutable');
|
|
}
|
|
});
|
|
|
|
it('index.html and non-asset files revalidate (no immutable caching)', async () => {
|
|
for (const revalidating of ['/', '/chat', '/favicon.svg']) {
|
|
const res = await request(app.getHttpServer()).get(revalidating);
|
|
expect(res.headers['cache-control'], revalidating).not.toContain('immutable');
|
|
}
|
|
});
|
|
|
|
it('non-GET unmatched requests keep the stock 404 (catch-all is GET/HEAD only)', async () => {
|
|
const res = await request(app.getHttpServer()).post('/chat');
|
|
expect(res.status).toBe(404);
|
|
expect(res.text).not.toContain('mosaic spa fixture');
|
|
});
|
|
});
|
|
|
|
describe('SPA static serving — configuration edges', () => {
|
|
it('WEB_DIST_DIR unset disables SPA serving', async () => {
|
|
const previous = process.env['WEB_DIST_DIR'];
|
|
delete process.env['WEB_DIST_DIR'];
|
|
try {
|
|
const app = await createApp();
|
|
const res = await request(app.getHttpServer()).get('/chat');
|
|
expect(res.status).toBe(404);
|
|
await app.close();
|
|
} finally {
|
|
if (previous !== undefined) {
|
|
process.env['WEB_DIST_DIR'] = previous;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('WEB_DIST_DIR without index.html fails at boot', async () => {
|
|
const emptyDir = await mkdtemp(path.join(tmpdir(), 'serve-spa-empty-'));
|
|
const previous = process.env['WEB_DIST_DIR'];
|
|
process.env['WEB_DIST_DIR'] = emptyDir;
|
|
try {
|
|
await expect(createApp()).rejects.toThrow(/index\.html.*does not exist/);
|
|
} finally {
|
|
if (previous === undefined) {
|
|
delete process.env['WEB_DIST_DIR'];
|
|
} else {
|
|
process.env['WEB_DIST_DIR'] = previous;
|
|
}
|
|
await rm(emptyDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|