Files

352 lines
11 KiB
TypeScript

// SPEC: PJ2026-010405 云端控制台.
// Implementation reference: draft-2026-07-13-p0-cloud-console.
import { expect, test } from "../fixtures/test";
const viewports = [
{ name: "desktop", width: 1920, height: 1080 },
{ name: "compact", width: 960, height: 600 },
{ name: "mobile", width: 390, height: 844 },
] as const;
test("industrial AppShell keeps one bounded scroll owner across target viewports", async ({
page,
}, testInfo) => {
await mockProviderProfiles(page);
for (const viewport of viewports) {
await page.setViewportSize({
width: viewport.width,
height: viewport.height,
});
await page.goto("/admin/provider-profiles");
await expect(
page.getByRole("heading", { name: "Provider Profiles", exact: true }),
).toBeVisible();
await expect(page.locator(".resource-collection")).toContainText(
"codex-api",
);
const geometry = await page.evaluate(() => {
const shell = document.querySelector<HTMLElement>(".platform-shell");
const content = document.querySelector<HTMLElement>(".platform-content");
const sidebar = document.querySelector<HTMLElement>(".platform-sidebar");
return {
documentScrollWidth: Math.round(document.documentElement.scrollWidth),
bodyScrollWidth: Math.round(document.body.scrollWidth),
shellHeight: Math.round(shell?.getBoundingClientRect().height ?? 0),
contentOverflowY: content
? getComputedStyle(content).overflowY
: "missing",
sidebarDisplay: sidebar ? getComputedStyle(sidebar).display : "missing",
};
});
expect(geometry.documentScrollWidth).toBeLessThanOrEqual(viewport.width);
expect(geometry.bodyScrollWidth).toBeLessThanOrEqual(viewport.width);
expect(geometry.shellHeight).toBe(viewport.height);
expect(geometry.contentOverflowY).toBe("auto");
expect(geometry.sidebarDisplay).toBe(
viewport.name === "mobile" ? "none" : "flex",
);
await page.screenshot({
path: testInfo.outputPath(`console-${viewport.name}.png`),
});
}
});
test("typed Dashboard renders status, problems and actions without horizontal overflow", async ({
page,
}, testInfo) => {
for (const viewport of viewports) {
await page.setViewportSize({
width: viewport.width,
height: viewport.height,
});
await page.goto("/dashboard");
await expect(page.getByText("HWPOD Node 离线")).toBeVisible();
await expect(page.getByText("展示 MDTODO 任务详情")).toBeVisible();
await expect(
page.getByRole("link", { name: /查看 Node/u }),
).toHaveAttribute("href", "/hwpods/nodes?status=offline");
const width = await page.evaluate(
() => document.documentElement.scrollWidth,
);
expect(width).toBeLessThanOrEqual(viewport.width);
await page.screenshot({
path: testInfo.outputPath(`dashboard-${viewport.name}.png`),
fullPage: true,
});
}
});
test("Dialog and mobile Drawer trap, restore and unlock focus without motion", async ({
page,
}, testInfo) => {
await mockProviderProfiles(page);
await page.emulateMedia({ reducedMotion: "reduce" });
await page.setViewportSize({ width: 1920, height: 1080 });
await page.goto("/admin/provider-profiles");
const keyButton = page
.getByRole("button", { name: "Key", exact: true })
.first();
await keyButton.click();
const dialog = page.getByRole("dialog", { name: "Provider Credential" });
await expect(dialog).toBeVisible();
await expect(page.locator("body")).toHaveClass(/console-scroll-locked/u);
await expect(dialog.locator("#provider-api-key-input")).toBeVisible();
await page.screenshot({
path: testInfo.outputPath("console-overlay-desktop.png"),
});
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await expect(page.locator("body")).not.toHaveClass(/console-scroll-locked/u);
await expect(keyButton).toBeFocused();
await page.setViewportSize({ width: 390, height: 844 });
const mobileTrigger = page.getByRole("button", { name: "打开主导航" });
await mobileTrigger.click();
const drawer = page.getByRole("dialog", { name: "HWLAB 导航" });
await expect(drawer).toBeVisible();
await expect(
drawer.getByRole("navigation", { name: "主导航" }),
).toBeVisible();
await expect(page.locator("body")).toHaveClass(/console-scroll-locked/u);
await page.keyboard.press("Escape");
await expect(drawer).toHaveCount(0);
await expect(mobileTrigger).toBeFocused();
await expect(page.locator("body")).not.toHaveClass(/console-scroll-locked/u);
});
test("URL is authoritative across direct links and browser back-forward navigation", async ({
page,
}) => {
await mockBillingConsole(page);
await page.goto("/admin/users?q=alpha&filter=active&role=admin&cursor=2");
await expect(page.getByPlaceholder("搜索用户名、邮箱或 user id")).toHaveValue(
"alpha",
);
await expect(page.locator(".admin-users-filters select").nth(0)).toHaveValue(
"active",
);
await expect(page.locator(".admin-users-filters select").nth(1)).toHaveValue(
"admin",
);
await expect(page.getByText("第 2 / 3 页,共 60 条")).toBeVisible();
await page.goto("/admin/users?q=beta&filter=disabled&role=user&cursor=1");
await expect(page.getByPlaceholder("搜索用户名、邮箱或 user id")).toHaveValue(
"beta",
);
await page.goBack();
await expect(page.getByPlaceholder("搜索用户名、邮箱或 user id")).toHaveValue(
"alpha",
);
await expect(page.getByText("第 2 / 3 页,共 60 条")).toBeVisible();
await page.goForward();
await expect(page.getByPlaceholder("搜索用户名、邮箱或 user id")).toHaveValue(
"beta",
);
await page.goto(
"/admin/billing?tab=redemptions&view=detail&userId=user-a&codeId=code-a",
);
await expect(page.getByPlaceholder("user id")).toHaveValue("user-a");
await expect(page.getByPlaceholder("code id")).toHaveValue("code-a");
await page.goto(
"/admin/billing?tab=redemptions&view=summary&userId=user-b&codeId=code-b",
);
await expect(page.getByPlaceholder("user id")).toHaveValue("user-b");
await page.goBack();
await expect(page.getByPlaceholder("user id")).toHaveValue("user-a");
await expect(page.getByPlaceholder("code id")).toHaveValue("code-a");
await page.goto("/performance?filter=24h");
await expect(page.getByRole("tab", { name: "24 小时" })).toHaveAttribute(
"aria-selected",
"true",
);
await page.goto("/performance?filter=5m");
await expect(
page.getByRole("tab", { name: "5 分钟", exact: true }),
).toHaveAttribute("aria-selected", "true");
await page.goBack();
await expect(page.getByRole("tab", { name: "24 小时" })).toHaveAttribute(
"aria-selected",
"true",
);
await page.goto("/projects/mdtodo?provider=deepseek");
await expect(page.getByTestId("mdtodo-provider-profile")).toHaveValue(
"deepseek",
);
await page.goto("/projects/mdtodo?provider=codex-api");
await expect(page.getByTestId("mdtodo-provider-profile")).toHaveValue(
"codex-api",
);
await page.goBack();
await expect(page.getByTestId("mdtodo-provider-profile")).toHaveValue(
"deepseek",
);
});
test("19 console routes stay visible and bounded at 960 and 390", async ({
page,
}) => {
await mockProviderProfiles(page);
await mockBillingConsole(page);
const routes = [
"/dashboard",
"/projects",
"/projects/mdtodo",
"/hwpods/devices",
"/hwpods/nodes",
"/hwpods/onboarding",
"/admin/users",
"/api-keys",
"/usage",
"/billing",
"/skills",
"/gate",
"/help",
"/settings",
"/admin/billing",
"/admin/provider-profiles",
"/performance",
"/login",
"/register",
];
const pageErrors: Error[] = [];
page.on("pageerror", (error) => pageErrors.push(error));
for (const viewport of [
{ width: 960, height: 720 },
{ width: 390, height: 844 },
]) {
await page.setViewportSize(viewport);
for (const path of routes) {
const errorCount = pageErrors.length;
await page.goto(path);
await expect(
page.locator("h1").first(),
`route ${path} should expose its primary heading`,
).toBeVisible();
const geometry = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
expect(
geometry.scrollWidth,
`route ${path} should not overflow at ${viewport.width}px`,
).toBeLessThanOrEqual(geometry.clientWidth);
expect(
pageErrors.slice(errorCount),
`route ${path} should not raise pageerror`,
).toEqual([]);
}
}
});
async function mockProviderProfiles(
page: import("@playwright/test").Page,
): Promise<void> {
await page.route("**/v1/admin/provider-profiles", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
profile: "codex-api",
configured: true,
secretRef: {
namespace: "hwlab-v03",
name: "gpt-pika",
keys: ["auth.json"],
},
credentialHashSuffix: "a19f",
validationStatus: "completed",
valuesPrinted: false,
},
{
profile: "deepseek",
configured: true,
secretRef: {
namespace: "hwlab-v03",
name: "deepseek",
keys: ["api-key"],
},
credentialHashSuffix: "b470",
validationStatus: "pending",
valuesPrinted: false,
},
],
}),
});
});
}
async function mockBillingConsole(
page: import("@playwright/test").Page,
): Promise<void> {
await page.route("**/v1/admin/billing/plans", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
json: { plans: [] },
}),
);
await page.route("**/v1/admin/billing/users?**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
json: {
users: [
{
user: {
id: "usr-1",
username: "alpha",
email: "alpha@example.com",
role: "admin",
status: "active",
},
planId: "default",
credits: { balance: 100, reserved: 10, available: 90 },
usage: { totalCredits: 12, recordCount: 3 },
apiKeys: { activeCount: 1, totalCount: 2 },
},
],
total: 60,
state: { stateAuthority: "pk01-postgres" },
},
}),
);
await page.route("**/v1/admin/billing/usage?**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
json: { rows: [], total: 0 },
}),
);
await page.route("**/v1/admin/billing/ledger?**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
json: { rows: [], total: 0 },
}),
);
await page.route("**/v1/admin/billing/redeem-codes?**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
json: { codes: [], count: 0 },
}),
);
await page.route("**/v1/admin/billing/redeem-redemptions?**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
json: { redemptions: [], count: 0 },
}),
);
}