RSS Git Download  Clone
Raw Blame History 11kB 314 lines
const assert = require('node:assert');
const http = require('node:http');
const path = require('node:path');
const { writeFileSync, unlinkSync, mkdirSync } = require('node:fs');
const express = require('express');

const { botSsr, angularBotSsr } = require('../../src');

const SHELL_HTML = '<html><body>CSR shell</body></html>';
const browserDistFolder = path.join(__dirname, 'fixture-browser');
const shellPath = path.join(browserDistFolder, 'index.csr.html');

const CHROME_UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const GOOGLEBOT_UA = 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)';

function startServer(app) {
    return new Promise((resolve) => {
        const server = app.listen(0, () => resolve(server));
    });
}

function get(server, ua) {
    return new Promise((resolve, reject) => {
        const port = server.address().port;
        http.get({
            hostname: '127.0.0.1',
            port,
            path: '/',
            headers: ua ? { 'user-agent': ua } : {},
        }, (res) => {
            let body = '';
            res.on('data', (c) => (body += c));
            res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
        }).on('error', reject);
    });
}

function buildApp({ ssrCalls, ssrResponse }) {
    const app = express();
    app.use(botSsr({
        shell: shellPath,
        ssr: (req) => {
            ssrCalls.push(req.headers['user-agent']);
            return Promise.resolve(ssrResponse);
        },
        writeResponse: (response, res) => {
            res.setHeader('Content-Type', 'text/html; charset=utf-8');
            res.statusCode = response.status;
            res.end(response.body);
        },
    }));
    return app;
}

describe('ngx-bot-ssr — botSsr (low level)', () => {

    before(() => {
        mkdirSync(browserDistFolder, { recursive: true });
        writeFileSync(shellPath, SHELL_HTML, 'utf-8');
    });

    after(() => {
        try { unlinkSync(shellPath); } catch (_) { /* ignore */ }
    });

    it('serves CSR shell to a real browser (Chrome UA)', async () => {
        const ssrCalls = [];
        const server = await startServer(buildApp({ ssrCalls, ssrResponse: null }));
        try {
            const res = await get(server, CHROME_UA);
            assert.strictEqual(res.status, 200);
            assert.ok(res.body.includes('CSR shell'));
            assert.strictEqual(res.headers['vary'], 'User-Agent');
            assert.strictEqual(ssrCalls.length, 0);
        } finally {
            server.close();
        }
    });

    it('runs SSR for Googlebot', async () => {
        const ssrCalls = [];
        const ssrResponse = { status: 200, body: '<html><body>prerendered</body></html>' };
        const server = await startServer(buildApp({ ssrCalls, ssrResponse }));
        try {
            const res = await get(server, GOOGLEBOT_UA);
            assert.strictEqual(res.status, 200);
            assert.ok(res.body.includes('prerendered'));
            assert.strictEqual(ssrCalls.length, 1);
        } finally {
            server.close();
        }
    });

    it('treats missing User-Agent as a bot', async () => {
        const ssrCalls = [];
        const ssrResponse = { status: 200, body: '<html><body>prerendered</body></html>' };
        const server = await startServer(buildApp({ ssrCalls, ssrResponse }));
        try {
            const res = await get(server, undefined);
            assert.strictEqual(res.status, 200);
            assert.ok(res.body.includes('prerendered'));
            assert.strictEqual(ssrCalls.length, 1);
        } finally {
            server.close();
        }
    });

    it('falls through to next() when ssr returns null/undefined', async () => {
        const ssrCalls = [];
        const app = express();
        app.use(botSsr({
            shell: shellPath,
            ssr: (req) => {
                ssrCalls.push(req.headers['user-agent']);
                return Promise.resolve(null);
            },
            writeResponse: () => { throw new Error('writeResponse should not be called when ssr returns null'); },
        }));
        app.use((req, res) => res.status(404).end('next-fallback'));

        const server = await startServer(app);
        try {
            const res = await get(server, GOOGLEBOT_UA);
            assert.strictEqual(res.status, 404);
            assert.strictEqual(res.body, 'next-fallback');
            assert.strictEqual(ssrCalls.length, 1);
        } finally {
            server.close();
        }
    });

    it('forwards thrown SSR errors to next(err)', async () => {
        const app = express();
        app.use(botSsr({
            shell: shellPath,
            ssr: () => Promise.reject(new Error('boom')),
            writeResponse: () => { throw new Error('writeResponse should not be called on error'); },
        }));
        app.use((err, req, res, _next) => {
            res.status(500).end(`error: ${err.message}`);
        });

        const server = await startServer(app);
        try {
            const res = await get(server, GOOGLEBOT_UA);
            assert.strictEqual(res.status, 500);
            assert.strictEqual(res.body, 'error: boom');
        } finally {
            server.close();
        }
    });

    it('honours options.isBot override', async () => {
        const ssrCalls = [];
        const app = express();
        app.use(botSsr({
            shell: shellPath,
            isBot: () => true,
            ssr: (req) => {
                ssrCalls.push(req.headers['user-agent']);
                return Promise.resolve({ status: 200, body: 'forced-ssr' });
            },
            writeResponse: (response, res) => {
                res.statusCode = response.status;
                res.end(response.body);
            },
        }));
        const server = await startServer(app);
        try {
            const res = await get(server, CHROME_UA);
            assert.strictEqual(res.body, 'forced-ssr');
            assert.strictEqual(ssrCalls.length, 1);
        } finally {
            server.close();
        }
    });

    it('honours options.shellCacheControl', async () => {
        const app = express();
        app.use(botSsr({
            shell: shellPath,
            shellCacheControl: 'public, max-age=60',
            ssr: () => Promise.resolve(null),
            writeResponse: () => {},
        }));
        const server = await startServer(app);
        try {
            const res = await get(server, CHROME_UA);
            assert.strictEqual(res.headers['cache-control'], 'public, max-age=60');
        } finally {
            server.close();
        }
    });

    it('throws on missing required options', () => {
        assert.throws(() => botSsr(), /options required/);
        assert.throws(() => botSsr({}), /options\.shell/);
        assert.throws(() => botSsr({ shell: shellPath }), /options\.ssr/);
        assert.throws(() => botSsr({ shell: shellPath, ssr: () => null }), /options\.writeResponse/);
    });

    it('throws ENOENT at construction if shell does not exist', () => {
        assert.throws(
            () => botSsr({
                shell: path.join(browserDistFolder, 'nope.html'),
                ssr: () => null,
                writeResponse: () => {},
            }),
            /ENOENT/,
        );
    });
});

describe('ngx-bot-ssr — angularBotSsr (Angular SSR sugar)', () => {

    before(() => {
        mkdirSync(browserDistFolder, { recursive: true });
        writeFileSync(shellPath, SHELL_HTML, 'utf-8');
    });

    after(() => {
        try { unlinkSync(shellPath); } catch (_) { /* ignore */ }
    });

    function fakeAngularApp(handler) {
        return { handle: handler };
    }

    function fakeWriteResponse(response, res) {
        res.setHeader('Content-Type', 'text/html; charset=utf-8');
        res.statusCode = response.status;
        res.end(response.body);
    }

    it('wires browserDistFolder + angularApp + writeResponseToNodeResponse with no glue code', async () => {
        const ssrCalls = [];
        const angularApp = fakeAngularApp((req) => {
            ssrCalls.push(req.headers['user-agent']);
            return Promise.resolve({ status: 200, body: '<html>angular ssr</html>' });
        });

        const app = express();
        app.use(angularBotSsr({
            browserDistFolder,
            angularApp,
            writeResponseToNodeResponse: fakeWriteResponse,
        }));

        const server = await startServer(app);
        try {
            const bot = await get(server, GOOGLEBOT_UA);
            assert.ok(bot.body.includes('angular ssr'));
            assert.strictEqual(ssrCalls.length, 1);

            const human = await get(server, CHROME_UA);
            assert.ok(human.body.includes('CSR shell'));
            assert.strictEqual(ssrCalls.length, 1);
        } finally {
            server.close();
        }
    });

    it('derives shell path from browserDistFolder/index.csr.html', async () => {
        const angularApp = fakeAngularApp(() => Promise.resolve({ status: 200, body: 'ssr' }));
        const app = express();
        app.use(angularBotSsr({
            browserDistFolder,
            angularApp,
            writeResponseToNodeResponse: fakeWriteResponse,
        }));
        const server = await startServer(app);
        try {
            const res = await get(server, CHROME_UA);
            assert.ok(res.body.includes('CSR shell'));
        } finally {
            server.close();
        }
    });

    it('respects an explicit shell override', async () => {
        const customShell = path.join(browserDistFolder, 'custom.csr.html');
        writeFileSync(customShell, '<html>custom shell</html>', 'utf-8');
        try {
            const angularApp = fakeAngularApp(() => Promise.resolve(null));
            const app = express();
            app.use(angularBotSsr({
                browserDistFolder,
                shell: customShell,
                angularApp,
                writeResponseToNodeResponse: fakeWriteResponse,
            }));
            const server = await startServer(app);
            try {
                const res = await get(server, CHROME_UA);
                assert.ok(res.body.includes('custom shell'));
            } finally {
                server.close();
            }
        } finally {
            try { unlinkSync(customShell); } catch (_) { /* ignore */ }
        }
    });

    it('throws helpful errors on missing options', () => {
        assert.throws(() => angularBotSsr(), /options required/);
        assert.throws(() => angularBotSsr({}), /browserDistFolder/);
        assert.throws(() => angularBotSsr({ browserDistFolder }), /angularApp/);
        assert.throws(
            () => angularBotSsr({ browserDistFolder, angularApp: { handle: () => null } }),
            /writeResponseToNodeResponse/,
        );
    });
});