mirror of
https://github.com/KivotosRailway/website.git
synced 2026-11-03 11:06:32 +09:00
优化页面数据加载并完善多语言 SEO、新闻回退、主题首屏及自动化回归测试
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { siteOrigin } from "../../src/lib/seo";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Keep UI tests deterministic and independent of external image hosts.
|
||||
await page.route(/^https?:\/\/(?!127\.0\.0\.1:4173)/, (route) => route.abort());
|
||||
});
|
||||
|
||||
test("exported HTML has correct languages, canonical URLs and social metadata before JavaScript", async ({ request }) => {
|
||||
for (const [locale, language] of [["zh-Hans", "zh-Hans"], ["zh-Hant", "zh-Hant"], ["en", "en"], ["jp", "ja"]]) {
|
||||
for (const suffix of ["", "news/", "policy/privacy-policy/"]) {
|
||||
const response = await request.get(`/${locale}/${suffix}`);
|
||||
expect(response.status()).toBe(200);
|
||||
const html = await response.text();
|
||||
expect(html).toContain(`lang="${language}"`);
|
||||
expect(html).toContain(`rel="canonical" href="${siteOrigin}/${locale}/${suffix}"`);
|
||||
expect(html).toContain('hrefLang="ja"');
|
||||
expect(html).toContain('property="og:title"');
|
||||
expect(html).toContain('name="twitter:card"');
|
||||
expect(html).toContain('id="kr-theme-init"');
|
||||
expect(html.indexOf('id="kr-theme-init"')).toBeLessThan(html.indexOf("</head>"));
|
||||
expect(html).not.toContain('\\"body\\":');
|
||||
if (!suffix) expect(Buffer.byteLength(html)).toBeLessThan(70_000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("sitemap URLs resolve, expose all languages and omit error routes", async ({ request }) => {
|
||||
const robots = await request.get("/robots.txt");
|
||||
expect(await robots.text()).toContain(`Sitemap: ${siteOrigin}/sitemap.xml`);
|
||||
const response = await request.get("/sitemap.xml");
|
||||
expect(response.status()).toBe(200);
|
||||
const xml = await response.text();
|
||||
expect(xml).toContain('hreflang="ja"');
|
||||
const urls = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) => match[1]);
|
||||
expect(urls.length).toBeGreaterThan(200);
|
||||
expect(urls.some((url) => /\/(403|404|500|502|503|504)\//.test(url))).toBe(false);
|
||||
for (const url of urls) {
|
||||
expect((await request.get(new URL(url).pathname)).status(), url).toBe(200);
|
||||
}
|
||||
expect((await request.get("/en/non-existent-page/")).status()).toBe(404);
|
||||
});
|
||||
|
||||
test("footer navigation, language changes and back navigation stay in the same document", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto("/zh-Hans/");
|
||||
await page.locator('.theme-toggle button[aria-pressed="true"]').waitFor();
|
||||
await page.evaluate(() => { document.documentElement.dataset.navigationCheck = "same-document"; });
|
||||
await page.locator('.site-footer a[href="/zh-Hans/members/"]').click();
|
||||
await expect(page).toHaveURL(/\/zh-Hans\/members\/$/);
|
||||
await page.locator('.site-locale button[aria-label="English"]').click();
|
||||
await expect(page).toHaveURL(/\/en\/members\/$/);
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en");
|
||||
await expect(page.locator('.site-footer a[href="/en/join-us/"]')).toBeVisible();
|
||||
await page.locator('.site-locale button[aria-label="日本語"]').click();
|
||||
await expect(page).toHaveURL(/\/jp\/members\/$/);
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "ja");
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/zh-Hans\/$/);
|
||||
await expect(page.locator("html")).toHaveAttribute("data-navigation-check", "same-document");
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
for (const [system, saved] of [["light", "dark"], ["dark", "light"]] as const) {
|
||||
test(`saved ${saved} theme applies before React on a ${system} system`, async ({ page, context }) => {
|
||||
await page.emulateMedia({ colorScheme: system });
|
||||
await context.addCookies([{ name: "kr-theme", value: saved, url: "http://127.0.0.1:4173" }]);
|
||||
await page.route("**/_next/**/*.js", (route) => route.abort());
|
||||
await page.goto("/en/", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", saved);
|
||||
await expect(page.locator("body")).toHaveCSS("background-color", saved === "dark" ? "rgb(16, 21, 29)" : "rgb(255, 255, 255)");
|
||||
expect(await page.locator('meta[name="theme-color"]').first().getAttribute("content")).toBe(saved === "dark" ? "#10151d" : "#ffffff");
|
||||
});
|
||||
}
|
||||
|
||||
test("theme switching persists through refresh, SPA navigation and mobile menu opening", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
await page.goto("/en/");
|
||||
await page.locator('.theme-toggle button').first().click();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
await page.reload();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
await page.locator('.site-footer a[href="/en/members/"]').click();
|
||||
await expect(page).toHaveURL(/\/en\/members\/$/);
|
||||
await page.locator(".site-menu-button").click();
|
||||
await expect(page.locator(".site-navigation")).toHaveCSS("background-color", "rgb(26, 34, 45)");
|
||||
await expect(page.locator('meta[name="theme-color"]').first()).toHaveAttribute("content", "#10151d");
|
||||
await page.locator('.theme-toggle button').last().click();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
});
|
||||
|
||||
test("themes still work when localStorage is blocked", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(Storage.prototype, "getItem", { value: () => { throw new Error("blocked"); } });
|
||||
Object.defineProperty(Storage.prototype, "setItem", { value: () => { throw new Error("blocked"); } });
|
||||
});
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await page.goto("/en/");
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
await page.locator('.theme-toggle button').nth(1).click();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readdir, readFile, access } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { locales, withLocale } from "../../src/lib/i18n";
|
||||
|
||||
// Existing issue #1 is outside this change. Keep the exact baseline visible;
|
||||
// any newly missing asset fails this test. Remove entries when the cache is repaired.
|
||||
const knownMissing = new Set([
|
||||
"/media/news/897d764a65897170.jpg", "/media/news/8ecb7a6b90f318e0.png",
|
||||
"/media/news/852dd5b369bf377d.jpg", "/media/news/7f7c61ff4b22140d.png",
|
||||
"/media/news/acd56aafe8b41f05.png", "/media/news/7ae2b8fba574195b.png",
|
||||
"/media/news/faa9264b9cbf052b.png", "/media/news/02882b262ac53ec2.png",
|
||||
// This relative cover already exists in all four source files in HEAD.
|
||||
...locales.map((locale) => `${withLocale("/news/20240917-mcnews", locale)}2024-09-18_00.24.45.png`),
|
||||
]);
|
||||
|
||||
async function files(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
return (await Promise.all(entries.map((entry) => entry.isDirectory()
|
||||
? files(path.join(directory, entry.name)) : [path.join(directory, entry.name)]))).flat();
|
||||
}
|
||||
|
||||
test("exported HTML/CSS references no newly missing static assets", async (t) => {
|
||||
const output = path.resolve("out");
|
||||
const assets = new Set<string>();
|
||||
for (const file of (await files(output)).filter((name) => /\.(html|css)$/.test(name))) {
|
||||
const text = (await readFile(file, "utf8"))
|
||||
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
|
||||
.replaceAll(""", '"').replaceAll("&", "&");
|
||||
const base = new URL(path.relative(output, file).replaceAll(path.sep, "/"), "https://export.invalid/");
|
||||
for (const match of text.matchAll(/(?:\bsrc=["']|url\(["']?)([^\s"'<>)]*)/g)) {
|
||||
if (!match[1]) continue;
|
||||
const url = new URL(match[1], base);
|
||||
if (url.origin === base.origin) assets.add(decodeURIComponent(url.pathname));
|
||||
}
|
||||
}
|
||||
const missing: string[] = [];
|
||||
for (const asset of assets) {
|
||||
try { await access(path.join(output, asset)); }
|
||||
catch { missing.push(asset); }
|
||||
}
|
||||
const baseline = missing.filter((asset) => knownMissing.has(asset));
|
||||
if (baseline.length) t.diagnostic(`Existing missing image URLs (excluded image repair): ${baseline.join(", ")}`);
|
||||
assert.deepEqual(missing.filter((asset) => !knownMissing.has(asset)), []);
|
||||
});
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: The translated second article
|
||||
date: 2026-10-03
|
||||
slug: two
|
||||
---
|
||||
English translation body.
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: 第四篇中文文章
|
||||
date: 2026-10-01
|
||||
slug: four
|
||||
---
|
||||
第四篇中文正文。
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: 最新中文文章
|
||||
date: 2026-10-04
|
||||
slug: one
|
||||
---
|
||||
只存在于中文的最新正文。
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: 第三篇中文文章
|
||||
date: 2026-10-02
|
||||
slug: three
|
||||
---
|
||||
第三篇中文正文。
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: 第二篇中文文章
|
||||
date: 2026-10-03
|
||||
slug: two
|
||||
---
|
||||
第二篇中文正文。
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { createNewsReader } from "../../src/data/news";
|
||||
import { isLocale, localeLanguageTags, withLocale } from "../../src/lib/i18n";
|
||||
import { languageAlternates, pageMetadata } from "../../src/lib/seo";
|
||||
|
||||
const news = createNewsReader(path.resolve("tests/fixtures/news"));
|
||||
|
||||
test("a partially translated catalog keeps new source articles and uses available translations", async () => {
|
||||
const list = await news.getNewsList("en");
|
||||
assert.deepEqual(list.map((item) => item.slug), ["one", "two", "three", "four"]);
|
||||
assert.equal(list[0].title, "最新中文文章");
|
||||
assert.equal(list[1].title, "The translated second article");
|
||||
for (const item of list) {
|
||||
assert.equal("body" in item, false);
|
||||
assert.equal("markdownFile" in item, false);
|
||||
}
|
||||
});
|
||||
|
||||
test("detail fallback is per slug and records the real content language", async () => {
|
||||
const fallback = await news.getNewsBySlug("one", "en");
|
||||
assert.equal(fallback?.contentLocale, "zh-Hans");
|
||||
assert.match(fallback?.body ?? "", /最新正文/);
|
||||
const translated = await news.getNewsBySlug("two", "en");
|
||||
assert.equal(translated?.contentLocale, "en");
|
||||
assert.match(translated?.body ?? "", /English translation/);
|
||||
assert.equal(await news.getNewsBySlug("missing", "en"), null);
|
||||
});
|
||||
|
||||
test("a missing language directory falls back and the homepage receives only three summaries", async () => {
|
||||
assert.equal((await news.getNewsList("jp")).length, 4);
|
||||
const latest = await news.getLatestNews("en");
|
||||
assert.deepEqual(latest.map((item) => item.slug), ["one", "two", "three"]);
|
||||
assert.ok(latest.every((item) => !("body" in item)));
|
||||
});
|
||||
|
||||
test("locale URLs retain query/hash and SEO language tags do not change public routes", () => {
|
||||
assert.equal(withLocale("/zh-Hans/news/?page=2&lang=zh-CN#recent", "jp"), "/jp/news/?page=2#recent");
|
||||
assert.equal(withLocale("mailto:connect@kivotos.cc", "en"), "mailto:connect@kivotos.cc");
|
||||
assert.equal(isLocale("ja"), false);
|
||||
assert.equal(localeLanguageTags.jp, "ja");
|
||||
assert.match(languageAlternates("/news").ja, /\/jp\/news\/$/);
|
||||
const metadata = pageMetadata("en", "/about", { title: "About" });
|
||||
assert.match(String(metadata.alternates?.canonical), /\/en\/about\/$/);
|
||||
assert.ok(metadata.twitter && "card" in metadata.twitter);
|
||||
assert.equal(metadata.twitter.card, "summary_large_image");
|
||||
});
|
||||
Reference in New Issue
Block a user