352 lines
11 KiB
TypeScript
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 },
|
|
}),
|
|
);
|
|
}
|