Files
wagtailcms-frontend/tests/seo.test.cjs
T

290 lines
15 KiB
JavaScript

const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const { test } = require("node:test");
const ts = require("typescript");
function loadModule(relativePath, mocks = {}, globals = {}) {
const source = fs.readFileSync(path.join(__dirname, "..", relativePath), "utf8");
const compiled = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX },
}).outputText;
const loadedModule = { exports: {} };
vm.runInNewContext(compiled, {
module: loadedModule, exports: loadedModule.exports, process, URLSearchParams, URL,
require: (name) => name in mocks ? mocks[name] : require(name),
...globals,
});
return loadedModule.exports;
}
const page = {
title: "Page title",
id: 1,
meta: { slug: "test-slug", seo_title: "Standard title", search_description: "Standard description" },
cover_image: { url: "https://cms.shiytech.cn/media/cover.png", title: "Cover" },
summary: "Detail summary",
intro: "Blog introduction",
published_at: "2026-10-10T23:00:00Z",
client_name: "Client",
industry: "Manufacturing",
tags: ["Django"],
body: [],
seo_title_override: "Custom title | Shiytech",
seo_description_override: "Custom description",
canonical_url: "https://www.shiytech.cn/products",
og_image: { url: "https://cms.shiytech.cn/media/share.png", title: "Share image" },
schema_json: { "@type": "CollectionPage" },
};
test("content services request covers, tags and column intros", async () => {
for (const [name, list, detail] of [
["product", "getProductList", "getProductBySlug"],
["solution", "getSolutionList", "getSolutionBySlug"],
["case", "getCaseStudyList", "getCaseStudyBySlug"],
["blog", "getBlogList", "getBlogBySlug"],
]) {
const calls = [];
const service = loadModule(`services/${name}.service.ts`, { "./api-client": { fetchAPI: async (url) => { calls.push(url); return { items: [page] }; } } });
await service[list]();
await service[detail]("a&b");
for (const url of calls) {
const query = new URLSearchParams(url.split("?")[1]);
assert.ok(query.get("fields").split(",").includes("cover_image"));
if (name === "blog") assert.ok(query.get("fields").split(",").includes("tags"));
}
assert.equal(new URLSearchParams(calls[1].split("?")[1]).get("slug"), "a&b");
}
let query;
const { getColumnPage } = loadModule("services/page.service.ts", { "./api-client": { fetchAPI: async (url) => { query = new URLSearchParams(url.split("?")[1]); return { items: [] }; } } });
assert.equal(await getColumnPage("blog.BlogIndexPage", "a&b"), null);
assert.equal(query.get("fields"), "intro");
assert.equal(query.get("slug"), "a&b");
});
test("content presentation handles covers, dates, tags and column fallback", async () => {
const content = loadModule("components/layout/PageContent.tsx", { "@/services/page.service": { getColumnPage: async () => ({ title: "Column", intro: "Introduction" }) } });
assert.equal(content.PageCover({ image: null }), null);
assert.equal(content.PageCover({ image: { url: "/cover.png", title: "Cover" } }).props.children.props.alt, "Cover");
assert.equal(content.PublicationDate({ value: "invalid" }), null);
assert.equal(content.PublicationDate({ value: "2026-10-10T23:00:00Z" }).props.children, "2026年10月11日");
assert.equal(content.BlogTags({ tags: [] }), null);
assert.equal(content.BlogTags({ tags: ["Django"] }).props.children[0].props.children[1], "Django");
const header = await content.ColumnHeading({ type: "blog.BlogIndexPage", slug: "blog", fallback: "Fallback" });
assert.equal(header.props.children[0].props.children, "Column");
assert.equal(header.props.children[1].props.children, "Introduction");
const empty = loadModule("components/layout/PageContent.tsx", { "@/services/page.service": { getColumnPage: async () => { throw new Error("offline"); } } });
assert.equal((await empty.ColumnHeading({ fallback: "Fallback" })).props.children[0].props.children, "Fallback");
});
test("SEO metadata includes absolute title, canonical and social image", () => {
const { createPageMetadata } = loadModule("services/seo.service.ts", { "./api-client": {} });
const metadata = createPageMetadata(page, "Fallback");
assert.equal(metadata.title.absolute, page.seo_title_override);
assert.equal(metadata.description, page.seo_description_override);
assert.equal(metadata.alternates.canonical, page.canonical_url);
assert.equal(metadata.openGraph.images[0].url, page.og_image.url);
assert.equal(metadata.twitter.card, "summary_large_image");
});
test("SEO falls back to standard Wagtail fields and handles missing pages", () => {
const { createPageMetadata } = loadModule("services/seo.service.ts", { "./api-client": {} });
const metadata = createPageMetadata({ ...page, seo_title_override: "", seo_description_override: "", og_image: null }, "Fallback");
assert.equal(metadata.title.absolute, page.meta.seo_title);
assert.equal(metadata.description, page.meta.search_description);
assert.equal(metadata.twitter.card, "summary");
assert.equal(createPageMetadata(null, "Fallback").title, "Fallback");
});
test("SEO lookup requests every SEO field and encodes slugs", async () => {
let requested;
const { getPageSEO, SEO_FIELDS } = loadModule("services/seo.service.ts", {
"./api-client": { fetchAPI: async (url) => { requested = url; return { items: [page] }; } },
});
assert.equal(await getPageSEO("products.ProductPage", "a&b"), page);
const query = new URLSearchParams(requested.split("?")[1]);
assert.equal(query.get("slug"), "a&b");
assert.equal(query.get("fields"), SEO_FIELDS);
for (const field of ["seo_title", "search_description", "og_image", "canonical_url", "schema_json"]) {
assert.ok(SEO_FIELDS.split(",").includes(field));
}
});
test("JSON-LD escapes script boundaries and suppresses empty data", async () => {
const schema = { "@type": "WebPage", description: "</script><script>alert(1)</script>" };
const { default: PageSEO } = loadModule("components/layout/PageSEO.tsx", {
"@/services/seo.service": { getPageSEO: async () => ({ schema_json: schema }) },
});
const element = await PageSEO({ type: "home.HomePage" });
const json = element.props.dangerouslySetInnerHTML.__html;
assert.ok(!json.includes("<"));
assert.deepEqual(JSON.parse(json), schema);
const empty = loadModule("components/layout/PageSEO.tsx", {
"@/services/seo.service": { getPageSEO: async () => ({ schema_json: {} }) },
});
assert.equal(await empty.default({ type: "home.HomePage" }), null);
});
test("page fetches share the publish cache tag without changing snippet tags", async () => {
const calls = [];
const { fetchAPI } = loadModule("services/api-client.ts", {}, {
fetch: async (url, options) => { calls.push(options); return { ok: true, json: async () => ({}) }; },
});
await fetchAPI("/api/v2/pages/?type=home.HomePage", { tags: ["home-page", "wagtail-pages"] });
assert.equal(calls[0].next.tags.join(","), "wagtail-pages,home-page");
await fetchAPI("/api/v1/custom/core/site-settings/", { tags: ["site-settings"] });
assert.equal(calls[1].next.tags.join(","), "site-settings");
});
test("API client separates container internal and browser addresses", async () => {
const env = { NEXT_PUBLIC_API_URL: "http://localhost:18000", API_INTERNAL_URL: "http://backend:8000" };
for (const browser of [false, true]) {
let requestedURL;
const globals = {
process: { env },
fetch: async (url) => { requestedURL = url; return { ok: true, json: async () => ({}) }; },
...(browser ? { window: {} } : {}),
};
const { fetchAPI } = loadModule("services/api-client.ts", {}, globals);
await fetchAPI("/api/v2/pages/");
assert.equal(requestedURL, `${browser ? env.NEXT_PUBLIC_API_URL : env.API_INTERNAL_URL}/api/v2/pages/`);
}
});
test("authenticated webhook expires shared cache immediately and rejects unauthorized requests", async () => {
const calls = [];
const { POST } = loadModule("app/api/revalidate/route.ts", {
"next/cache": { revalidateTag: (tag, profile) => calls.push({ tag, profile }) },
}, { process: { env: { REVALIDATE_SECRET: "test-secret" } } });
const request = (authorization) => ({
headers: { get: () => authorization },
json: async () => ({ tags: ["wagtail-pages"] }),
});
assert.equal((await POST(request("Bearer test-secret"))).status, 200);
assert.equal(calls[0].tag, "wagtail-pages");
assert.equal(calls[0].profile.expire, 0);
assert.equal((await POST(request("Bearer invalid"))).status, 401);
assert.equal(calls.length, 1);
});
test("all CMS page routes wire metadata and JSON-LD to the correct page model", async () => {
const routes = [
["app/page.tsx", "home.HomePage", undefined],
["app/products/page.tsx", "products.ProductIndexPage", "products"],
["app/solutions/page.tsx", "solutions.SolutionIndexPage", "solutions"],
["app/cases/page.tsx", "cases.CaseStudyIndexPage", "cases"],
["app/blog/page.tsx", "blog.BlogIndexPage", "blog"],
["app/contact/page.tsx", "core.ContactPage", "contact"],
["app/privacy-policy/page.tsx", "core.SimpleContentPage", "privacy-policy"],
["app/products/[slug]/page.tsx", "products.ProductPage", "test-slug"],
["app/solutions/[slug]/page.tsx", "solutions.SolutionPage", "test-slug"],
["app/cases/[slug]/page.tsx", "cases.CaseStudyPage", "test-slug"],
["app/blog/[slug]/page.tsx", "blog.BlogPage", "test-slug"],
];
const { createPageMetadata } = loadModule("services/seo.service.ts", { "./api-client": {} });
const PageSEO = () => null;
const content = { ColumnHeading: () => null, PageCover: () => null, PublicationDate: () => null, BlogTags: () => null };
const previews = [];
const service = {
getHomePage: async () => page, getContactPage: async () => page,
getProductList: async () => [page], getSolutionList: async () => [page],
getCaseStudyList: async () => [page], getBlogList: async () => [page],
getProductBySlug: async () => page, getSolutionBySlug: async () => page,
getCaseStudyBySlug: async () => page, getBlogBySlug: async () => page,
getSimpleContentPageBySlug: async () => page,
};
const mocks = {
"@/components/layout/PageSEO": { default: PageSEO },
"@/components/layout/PageContent": content,
"@/blocks/BlockRenderer": { BlockRenderer: () => null },
"@/services/preview.service": { getPreviewOrFallback: async (type, fields, fallback) => { previews.push(fields); return fallback(); } },
};
for (const name of ["page", "product", "solution", "case", "blog", "legal"]) {
mocks[`@/services/${name}.service`] = service;
}
function findElement(element, type) {
if (!element || typeof element !== "object") return null;
if (element.type === type) return element;
const children = Array.isArray(element) ? element : element.props?.children;
for (const child of Array.isArray(children) ? children : [children]) {
const found = findElement(child, type);
if (found) return found;
}
return null;
}
for (const [file, expectedType, expectedSlug] of routes) {
const calls = [];
const route = loadModule(file, {
...mocks,
"@/services/seo.service": {
createPageMetadata,
getPageSEO: async (type, slug) => { calls.push({ type, slug }); return page; },
},
});
const props = { params: Promise.resolve({ slug: "test-slug" }) };
assert.equal((await route.generateMetadata(props)).title.absolute, page.seo_title_override, file);
assert.equal(calls[0].type, expectedType, file);
assert.equal(calls[0].slug, expectedSlug, file);
const tree = await route.default(props);
const element = findElement(tree, PageSEO);
assert.ok(element, file);
assert.equal(element.props.type, expectedType, file);
assert.equal(element.props.slug, expectedSlug, file);
if (/^(products|solutions|cases|blog)\./.test(expectedType)) {
assert.equal(findElement(tree, content.PageCover).props.image, page.cover_image, file);
if (expectedType.endsWith("IndexPage")) {
const heading = findElement(tree, content.ColumnHeading);
assert.equal(heading.props.type, expectedType, file);
assert.equal(heading.props.slug, expectedSlug, file);
} else {
assert.ok(previews.at(-1).split(",").includes("cover_image"), file);
}
if (/^(blog|cases)\./.test(expectedType)) assert.equal(findElement(tree, content.PublicationDate).props.value, page.published_at, file);
if (expectedType.startsWith("blog.")) {
assert.equal(findElement(tree, content.BlogTags).props.tags, page.tags, file);
if (!expectedType.endsWith("IndexPage")) assert.ok(previews.at(-1).split(",").includes("tags"), file);
}
}
}
});
test("feature grid displays configured icon text", () => {
const { default: FeatureGrid } = loadModule("blocks/feature-grid/FeatureGrid.tsx");
const tree = FeatureGrid({ value: { items: [{ icon: "S", title: "Speed", description: "Fast" }] } });
const item = tree.props.children.props.children[1].props.children[0];
assert.equal(item.props.children[0].props.children, "S");
});
test("footer renders configured navigation, contact and social fields with CMS QR URL", async () => {
const settings = { company_name: "Company", contact_phone: "123456", contact_email: "hello@example.com", address: "Address", icp_number: "测试备案号", icp_url: "https://beian.miit.gov.cn/", wechat_account: "Account", weibo_url: "https://weibo.com/example", wechat_qrcode: { url: "/media/qr.png", title: "QR" } };
const { default: Footer } = loadModule("components/layout/Footer.tsx", {
"@/services/site-settings.service": { getSiteSettings: async () => settings },
"@/services/navigation.service": { getNavigationMenu: async (name) => { assert.equal(name, "footer"); return { items: [{ label: "Contact", url: "/contact", open_in_new_tab: true }] }; } },
}, { process: { env: { NEXT_PUBLIC_API_URL: "https://cms.example.com" } } });
const elements = [];
function collect(element) {
if (Array.isArray(element)) { element.forEach(collect); return; }
if (!element || typeof element !== "object") return;
elements.push(element);
collect(element.props?.children);
}
collect(await Footer());
for (const href of ["/contact", "tel:123456", "mailto:hello@example.com", settings.weibo_url]) assert.ok(elements.some((element) => element.props?.href === href));
const contact = elements.find((element) => element.props?.href === "/contact");
assert.equal(contact.props.target, "_blank");
assert.equal(contact.props.rel, "noreferrer");
assert.ok(elements.some((element) => element.props?.src === "https://cms.example.com/media/qr.png"));
assert.ok(elements.some((element) => element.props?.children === settings.address));
assert.ok(elements.some((element) => element.props?.children === settings.wechat_account));
assert.ok(elements.some((element) => element.props?.href === settings.icp_url));
assert.ok(elements.some((element) => element.props?.children === settings.icp_number));
const fallback = loadModule("components/layout/Footer.tsx", {
"@/services/site-settings.service": { getSiteSettings: async () => null },
"@/services/navigation.service": { getNavigationMenu: async () => null },
});
elements.length = 0;
collect(await fallback.default());
assert.ok(elements.some((element) => element.props?.href === "/privacy-policy"));
assert.ok(!elements.some((element) => element.props?.href === settings.icp_url));
});