101 lines
4.3 KiB
TypeScript
101 lines
4.3 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
import {
|
|
detectPrintRestriction,
|
|
isPrintSupported,
|
|
printPersonalReport,
|
|
safeReportFilename,
|
|
} from "../src/lib/client-report-export.ts";
|
|
|
|
const exportSource = readFileSync(
|
|
new URL("../src/lib/client-report-export.ts", import.meta.url),
|
|
"utf8",
|
|
);
|
|
|
|
const pageSource = readFileSync(
|
|
new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const documentViewSource = readFileSync(
|
|
new URL("../src/components/personal-report/personal-report-document-view.tsx", import.meta.url),
|
|
"utf8",
|
|
);
|
|
|
|
test("safeReportFilename strips path, query and control characters", () => {
|
|
assert.equal(safeReportFilename("../../etc/passwd?x=1&y=2"), "jyotisha-report-etc-passwd-x-1-y-2");
|
|
assert.equal(safeReportFilename("a b<c>d|e:f"), "jyotisha-report-a-b-c-d-e-f");
|
|
assert.equal(safeReportFilename(""), "jyotisha-report");
|
|
assert.equal(safeReportFilename(null as unknown as string), "jyotisha-report");
|
|
const cleaned = safeReportFilename("abcdefghijklmnopqrstuvwxyz0123456789_ABCDEFGHIJKLMNOPQRSTUVWXYZ-extra");
|
|
assert.ok(cleaned.startsWith("jyotisha-report-"));
|
|
assert.ok(cleaned.length <= "jyotisha-report-".length + 64);
|
|
assert.doesNotMatch(cleaned, /[^a-zA-Z0-9_-]/);
|
|
});
|
|
|
|
test("detectPrintRestriction flags WeChat in-app browser and nothing else", () => {
|
|
assert.equal(detectPrintRestriction("Mozilla/5.0 MicroMessenger/8.0.49").restricted, true);
|
|
assert.equal(detectPrintRestriction("Mozilla/5.0 (iPhone) Safari").restricted, false);
|
|
assert.equal(detectPrintRestriction(undefined).restricted, false);
|
|
assert.match(detectPrintRestriction("MicroMessenger").message ?? "", /系统浏览器/);
|
|
});
|
|
|
|
test("isPrintSupported requires a usable window.print", () => {
|
|
assert.equal(isPrintSupported(), false);
|
|
(globalThis as Record<string, unknown>).window = { print: () => undefined };
|
|
assert.equal(isPrintSupported(), true);
|
|
delete (globalThis as Record<string, unknown>).window;
|
|
});
|
|
|
|
test("printPersonalReport waits for fonts, then calls window.print exactly once", async () => {
|
|
const calls: string[] = [];
|
|
let resolveFonts: (() => void) | undefined;
|
|
const fontsReady = new Promise<void>((resolve) => {
|
|
resolveFonts = resolve;
|
|
});
|
|
const fakeDocument = { fonts: { ready: fontsReady }, title: "original-title" };
|
|
(globalThis as Record<string, unknown>).window = {
|
|
print: () => {
|
|
calls.push("print");
|
|
// Browsers derive the PDF filename from document.title at print time.
|
|
assert.equal(fakeDocument.title, "safe-filename-title");
|
|
},
|
|
};
|
|
(globalThis as Record<string, unknown>).document = fakeDocument;
|
|
const printPromise = printPersonalReport({ title: "safe-filename-title" });
|
|
// Fonts not ready yet: print must not have fired.
|
|
assert.deepEqual(calls, []);
|
|
resolveFonts?.();
|
|
await printPromise;
|
|
assert.deepEqual(calls, ["print"]);
|
|
// Original title restored afterwards.
|
|
assert.equal(fakeDocument.title, "original-title");
|
|
delete (globalThis as Record<string, unknown>).window;
|
|
delete (globalThis as Record<string, unknown>).document;
|
|
});
|
|
|
|
test("printPersonalReport is a no-op without window", async () => {
|
|
// window is already deleted; must not throw.
|
|
await printPersonalReport({ title: "x" });
|
|
});
|
|
|
|
test("client export never touches a server PDF pipeline", () => {
|
|
assert.doesNotMatch(exportSource, /api\/report_artifact/);
|
|
assert.doesNotMatch(exportSource, /html2canvas|jsPDF|jspdf|playwright|puppeteer|chromium/i);
|
|
assert.doesNotMatch(exportSource, /canvas|base64/i);
|
|
assert.doesNotMatch(exportSource, /\bfetch\s*\(/);
|
|
assert.match(exportSource, /window\.print/);
|
|
assert.match(exportSource, /document\.fonts/);
|
|
});
|
|
|
|
|
|
test("native print uses the same validated structured DOM for stored v1 and current v2", () => {
|
|
assert.match(pageSource, /safeParseReportDocument\(json\.reportDocument\)/);
|
|
assert.match(pageSource, /<PersonalReportDocumentView document=\{document\} \/>/);
|
|
assert.match(documentViewSource, /isReportDocumentV2\(document\)/);
|
|
assert.match(documentViewSource, /<VedicChartSvg chart=\{structuredChart\(chart\)\}/);
|
|
assert.doesNotMatch(documentViewSource, /dangerouslySetInnerHTML|srcDoc|<iframe/i);
|
|
assert.doesNotMatch(documentViewSource, /modelHtml|modelCss|modelSvg/i);
|
|
assert.match(exportSource, /window\.print/);
|
|
});
|