Files
paseo/packages/website/e2e/i18n.spec.ts
Mohamed Boudra bd15e40e94 Serve paseo.sh in Simplified Chinese (#7373)
* feat(website): serve paseo.sh in Simplified Chinese

Every page of the website now has a Simplified Chinese version under
/zh-CN: the interface, docs, blog posts, comparison pages, legal pages,
all changelog releases and the homepage mockup. English URLs are unchanged.

The URL alone decides the language. There is no browser-language redirect
and no stored preference, because either would have to redirect the
unprefixed English URLs that people share and search engines index.
Internal links carry the page's language instead, and hreflang alternates
point Chinese search results at /zh-CN.

Translated headings pin the English anchor with {#id}, so section links
work in both languages. content-translations.test.ts fails when a page
has no translation or a translation's headings drift from the English.
Changelog releases and plugin listings without a translation render
whole in English under a notice, so a release never waits on one.

Plugin manifests and the registry gain optional translations and
OVERVIEW.zh-CN.md fields; older clients and registries ignore them.

* feat(website): replace the native language select with an EN/ZH menu

The header showed an OS-native select whose popup did not match the site.
The trigger now shows the language abbreviated (EN, ZH) and opens a menu in
the app's dropdown style that names each language in itself, with its name
in the page language underneath. Rows are real links to the same page in
that language; arrows, Escape, outside presses and taps behave as a menu
button, and the menu keeps itself on screen when a narrow header pushes the
trigger toward an edge.

Also drops the stray space after the full-width comma in the Chinese
homepage subtitle.

* docs(website): translate main's new docs and 0.12.0 release notes

Main added Keep run workspaces to the schedules docs, rewrote plugin icons
around inline SVG, and promoted 0.12.0 with new entries. Bring the Chinese
pages and changelog in line so no section falls back to English.

* fix(server): leave plugin listing translations to the registry

The daemon rejected manifests whose translations the registry would refuse,
so a plugin main installs could fail to load here. Paseo never reads
translations; the registry validates them at publish. Docs now say which
checks are automated at publish and which are human review.

* docs(plugins): describe the registry's final overview translation discovery

* style(server): format manifest test

* test(app): measure sidebar rows one at a time

After a reload the developer error toast appears while expectVerticalOrder
measures every row at once. Each concurrent boundingBox runs the toast
locator handler, and only one of them resumes, so the poll timed out on
every attempt since #7424. Measuring sequentially keeps one action in flight.

* test(app): return sidebar row measuring to main

#7480 hides the developer toast instead of running a locator handler, so
measuring the rows concurrently no longer races it. Reverts 41ac23016.

* test(app): wait for the compact shell before opening compact Changes

The shell survives the breakpoint, so right after the resize the wide
Explorer could still be visible: the test skipped the Explorer toggle and
the tab then disappeared, or toggled at the wrong time. Wait for the
compact-shell marker, as the composer helpers do since #7424.

* docs(website): translate main's plugin file panel docs

* test(app): edit the code font size after the font family remount

A committed appearance change remounts the settings screen through
AppearanceStyleBoundary, and the new form starts from the stored size.
Filling the size before that remount lost the edit in CI.

* docs(website): translate main's client/ui import docs

Follows #7474: one /client/ui entry, StreamedText instead of useRevealedText,
SettingsGroup dropped from the settings table, and the new
'UI imports in v0.13' migration section.

* test(app): wait for the previous layout's file panel picker to leave

After a resize the old layout's picker animates out while the new one is
already mounted, so the Use button matched two elements and the strict
locator failed. Wait for the shell's layout and a single picker first.
2026-10-21 13:13:03 +07:00

186 lines
7.3 KiB
TypeScript

import { expect, test, type Page } from "playwright/test";
function languageButton(page: Page) {
return page.getByRole("button", { name: /^(Language|语言)/ }).first();
}
async function chooseLanguage(page: Page, language: string) {
await languageButton(page).click();
await page.getByRole("menuitemradio", { name: language }).click();
}
function describeAlternates(links: Element[]): string[] {
return links.map((link) => `${link.getAttribute("hreflang")} ${link.getAttribute("href")}`);
}
test.describe("server-rendered Chinese pages", () => {
test.use({ javaScriptEnabled: false });
test("render in Chinese with matching language metadata before any script runs", async ({
page,
}) => {
await page.goto("/zh-CN/docs/cli");
await expect(page.locator("html")).toHaveAttribute("lang", "zh-CN");
await expect(page).toHaveTitle("CLI 参考 - Paseo 文档");
await expect(page.getByRole("heading", { level: 1 })).toHaveText(/CLI 参考$/);
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
"href",
"https://paseo.sh/zh-CN/docs/cli",
);
const alternates = await page
.locator('link[rel="alternate"][hreflang]')
.evaluateAll(describeAlternates);
expect(alternates).toEqual([
"en https://paseo.sh/docs/cli",
"zh-CN https://paseo.sh/zh-CN/docs/cli",
"x-default https://paseo.sh/docs/cli",
]);
await expect(page.locator('meta[property="og:locale"]')).toHaveAttribute("content", "zh_CN");
});
test("keep English pages at their unprefixed URLs", async ({ page }) => {
await page.goto("/docs/cli");
await expect(page.locator("html")).toHaveAttribute("lang", "en");
await expect(page.getByRole("heading", { level: 1 })).toHaveText(/CLI reference$/);
});
test("land translated anchors on the English section ids", async ({ page }) => {
await page.goto("/zh-CN/docs/cli#running-agents");
await expect(page.locator("#running-agents")).toHaveText(/运行 Agent/);
await expect(page.locator("main")).not.toContainText("{#");
});
test("show the app's Chinese labels in the homepage mockup", async ({ page }) => {
await page.goto("/zh-CN");
const mockup = page.getByRole("img", {
name: "Paseo 桌面应用,并排打开了编程 Agent、一段对话和一份代码 diff",
});
await expect(mockup).toContainText("新建工作区");
await expect(mockup).toContainText("待查看");
await expect(mockup).toContainText("给 Agent 发消息");
await expect(mockup).not.toContainText("Ready to review");
});
test("keep no space after full-width punctuation in the homepage subtitle", async ({ page }) => {
await page.goto("/zh-CN");
await expect(page.getByText(/^同时运行多个编程 Agent/)).toHaveText(
"同时运行多个编程 Agent,在任何机器上。在桌前,也在手机上。",
);
});
});
test("switches language on the same page, keeping the query and then every link", async ({
page,
}) => {
await page.goto("/plugins/all?q=dark");
await chooseLanguage(page, "简体中文");
await expect(page).toHaveURL(/\/zh-CN\/plugins\/all\?q=dark$/);
await expect(page.locator("html")).toHaveAttribute("lang", "zh-CN");
await expect(page.getByRole("searchbox", { name: "搜索插件" })).toHaveValue("dark");
await page.getByRole("link", { name: "文档", exact: true }).first().click();
await expect(page).toHaveURL(/\/zh-CN\/docs$/);
await chooseLanguage(page, "English");
await expect(page).toHaveURL(/\/docs$/);
await expect(page.locator("html")).toHaveAttribute("lang", "en");
});
test("shows the language as an abbreviation and lists every language by name", async ({ page }) => {
await page.goto("/docs/cli#running-agents");
const button = languageButton(page);
await expect(button).toHaveText("EN");
await expect(button).toHaveAccessibleName("Language: English");
await button.click();
await expect(page.getByRole("menuitemradio", { name: "English" })).toHaveAttribute(
"aria-checked",
"true",
);
const chinese = page.getByRole("menuitemradio", { name: /简体中文/ });
await expect(chinese).toHaveAttribute("aria-checked", "false");
await expect(page.getByRole("menu")).toBeInViewport({ ratio: 1 });
await expect(chinese).toHaveAttribute("href", "/zh-CN/docs/cli#running-agents");
await page.mouse.click(5, 400);
await expect(page.getByRole("menu")).toHaveCount(0);
});
test("switches language from the keyboard", async ({ page }) => {
await page.goto("/");
await languageButton(page).focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("menuitemradio", { name: "English" })).toBeFocused();
await page.keyboard.press("Escape");
await expect(page.getByRole("menu")).toHaveCount(0);
await expect(languageButton(page)).toBeFocused();
await page.keyboard.press("ArrowDown");
await page.keyboard.press("ArrowDown");
await expect(page.getByRole("menuitemradio", { name: /简体中文/ })).toBeFocused();
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/zh-CN$/);
await expect(languageButton(page)).toHaveText("ZH");
await expect(languageButton(page)).toBeFocused();
});
test.describe("on a phone", () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
const cases = [
{ from: "/", choose: "简体中文", to: "/zh-CN", abbreviation: "ZH" },
{ from: "/zh-CN", choose: "English", to: "/", abbreviation: "EN" },
{ from: "/docs/cli", choose: "简体中文", to: "/zh-CN/docs/cli", abbreviation: "ZH" },
];
for (const { from, choose, to, abbreviation } of cases) {
test(`switches from ${from} to ${to} with taps`, async ({ page, baseURL }) => {
await page.goto(from);
await languageButton(page).tap();
await expect(page.getByRole("menu")).toBeInViewport({ ratio: 1 });
await page.getByRole("menuitemradio", { name: choose }).tap();
await expect(page).toHaveURL(new URL(to, baseURL).href);
await expect(languageButton(page)).toHaveText(abbreviation);
});
}
});
test("finds plugins by their Chinese and English listing text", async ({ page }) => {
await page.goto(`/zh-CN/plugins/all?q=${encodeURIComponent("深色")}`);
await expect(page.getByRole("link", { name: /Dracula/ })).toBeVisible();
await page.goto(`/zh-CN/plugins/all?q=${encodeURIComponent("传令官")}`);
await expect(page.getByRole("link", { name: /传令官/ })).toBeVisible();
await page.goto("/zh-CN/plugins/all?q=herald");
await expect(page.getByRole("link", { name: /传令官/ })).toBeVisible();
});
test("shows a translated plugin overview, or the English one under a notice", async ({ page }) => {
await page.goto("/zh-CN/plugins/omercnet/dracula");
await expect(page.getByRole("heading", { name: "行为" })).toBeVisible();
await expect(page.getByRole("note")).toHaveCount(0);
await page.goto("/zh-CN/plugins/gpambrozio/herald");
await expect(page.getByRole("heading", { level: 1 })).toHaveText("传令官");
await expect(page.getByRole("note")).toHaveText(
"该插件的概览尚未翻译为你的语言,因此以英文显示。",
);
await expect(page.getByRole("heading", { name: "Behavior" })).toBeVisible();
});
test("shows the changelog in Chinese", async ({ page }) => {
await page.goto("/zh-CN/changelog");
await expect(page.getByRole("heading", { name: "新增" }).first()).toBeVisible();
await expect(page.getByRole("note")).toHaveCount(0);
});