core/coordinators/viewport.coordinator.ts

core/coordinators/viewport.coordinator.ts is a file in Project Scripts. 105 lines of code and 19 definitions.

import { EMPTY_FRAME, NO_DEVTOOLS, summaryLine } from "#configuration/strings/viewport.strings";
import {
    VIEWPORT_DEFAULTS,
    VIEWPORT_PLATFORM,
    VIEWPORT_RULES,
    VIEWPORT_TOUCH_POINTS,
    VIEWPORT_USER_AGENT,
} from "#configuration/configs/viewport.config";
import { VIEWPORT_PROBE, auditPage, scrollThrough } from "#core/probes/viewport.probe";
import type { ViewportOptions, ViewportResult } from "#types/viewport.types";
import { auditExpression, resultLines, scrollExpression } from "#core/formatters/viewport.formatter";
import { auditOf, hasFinding, shotNameOf } from "#core/converters/viewport.converter";
import { devtoolsEndpoint, openSession } from "@banes-lab/build-scripts/core/adapters/browser.adapter.ts";
import { freshProfile, launchBrowser, settleBrowser } from "@banes-lab/build-scripts/core/factories/browser.factory.ts";
import { writeReport, writeShot } from "#core/persistence/viewport.persistence";
import type { Session } from "@banes-lab/build-scripts/types/browser.types.ts";
import process from "node:process";
import { serveBuiltSite } from "#core/adapters/viewport.adapter";
import { wait } from "@banes-lab/build-scripts/core/timers/base.timer.ts";

const AUDIT_EXPRESSION = auditExpression(auditPage, VIEWPORT_RULES, VIEWPORT_PROBE);
const SCROLL_EXPRESSION = scrollExpression(scrollThrough, VIEWPORT_RULES);

const emulatePhone = async function emulatePhone(session: Session, options: ViewportOptions): Promise<void> {
    await session.send("Page.enable");
    await session.send("Runtime.enable");
    await session.send("Emulation.setDeviceMetricsOverride", {
        deviceScaleFactor: VIEWPORT_DEFAULTS.scale,
        height: options.height,
        mobile: true,
        width: options.width,
    });
    await session.send("Emulation.setUserAgentOverride", {
        platform: VIEWPORT_PLATFORM,
        userAgent: VIEWPORT_USER_AGENT,
    });
    await session.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: VIEWPORT_TOUCH_POINTS });
};

const auditRoute = async function auditRoute(
    session: Session,
    options: ViewportOptions,
    origin: string,
    route: string,
): Promise<ViewportResult> {
    await session.send("Page.navigate", { url: origin + route });
    await wait(options.settleMs);
    await session.send("Runtime.evaluate", { awaitPromise: true, expression: SCROLL_EXPRESSION, returnByValue: true });
    const reply = await session.send("Runtime.evaluate", { expression: AUDIT_EXPRESSION, returnByValue: true });
    const { result } = reply;
    const audit = auditOf(typeof result === "object" && result !== null ? Reflect.get(result, "value") : null);
    if (audit === null) {
        throw new Error(JSON.stringify(reply["exceptionDetails"] ?? reply));
    }
    const { data } = await session.send("Page.captureScreenshot", { format: "png" });
    if (typeof data !== "string" || data.length === 0) {
        throw new Error(EMPTY_FRAME);
    }
    writeShot(options.outDir, shotNameOf(route), data);
    return { audit, route };
};

const auditInSequence = async function auditInSequence(
    session: Session,
    options: ViewportOptions,
    origin: string,
    routes: readonly string[],
): Promise<readonly ViewportResult[]> {
    return routes.reduce<Promise<readonly ViewportResult[]>>(async (previous, route) => {
        const done = await previous;
        const result = await auditRoute(session, options, origin, route);
        process.stdout.write(resultLines(result));
        return [...done, result];
    }, Promise.resolve([]));
};

export const auditRoutes = async function auditRoutes(
    binary: string,
    options: ViewportOptions,
    root: string,
    routes: readonly string[],
): Promise<boolean> {
    const site = await serveBuiltSite(root);
    const profile = freshProfile(options.outDir);
    const child = launchBrowser(binary, {
        height: options.height,
        profileDir: profile,
        software: options.software,
        width: options.width,
    });
    try {
        return await settleBrowser(child, async () => {
            const endpoint = await devtoolsEndpoint(profile, options.timeoutMs);
            if (endpoint === null) {
                process.stderr.write(NO_DEVTOOLS);
                return false;
            }
            const session = await openSession(endpoint, false);
            await emulatePhone(session, options);
            const results = await auditInSequence(session, options, site.origin, routes);
            session.close();
            writeReport(options.outDir, results);
            const failing = results.filter((result) => hasFinding(result.audit)).length;
            process.stdout.write(summaryLine(failing, results.length));
            return failing === 0;
        });
    } finally {
        site.close();
    }
};