412 lines
13 KiB
JavaScript
412 lines
13 KiB
JavaScript
/** 主题和字段回归经真实商品、首页、实名认证与实例入口验证;只替换外部 HTTP 响应。 */
|
|
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import { backendOrigin, login, scenario } from "./helpers/console-browser.mjs";
|
|
|
|
async function boundary(page, responses) {
|
|
const requests = [];
|
|
await page
|
|
.context()
|
|
.route(`${backendOrigin}/console/v1/**`, async (route) => {
|
|
const request = route.request();
|
|
const path = new URL(request.url()).pathname.replace(
|
|
/^\/console\/v1/,
|
|
"",
|
|
);
|
|
if (!Object.hasOwn(responses, path)) return route.fallback();
|
|
requests.push({
|
|
path,
|
|
authenticated: Boolean(request.headers().authorization),
|
|
multipart:
|
|
request
|
|
.headers()
|
|
["content-type"]?.startsWith("multipart/form-data; boundary=") ===
|
|
true,
|
|
});
|
|
await route.fulfill({
|
|
json: { status: 200, msg: "", data: responses[path] },
|
|
headers: { "Access-Control-Allow-Origin": "*" },
|
|
});
|
|
});
|
|
return requests;
|
|
}
|
|
|
|
async function textPresentation(locator) {
|
|
return locator.evaluate((element) => {
|
|
const style = getComputedStyle(element);
|
|
const rgb = (value) => value.match(/[\d.]+/g).map(Number);
|
|
let background = [255, 255, 255];
|
|
const layers = [];
|
|
for (let owner = element; owner; owner = owner.parentElement)
|
|
layers.push(rgb(getComputedStyle(owner).backgroundColor));
|
|
for (const layer of layers.reverse()) {
|
|
const alpha = layer[3] ?? 1;
|
|
background = background.map(
|
|
(channel, index) => channel * (1 - alpha) + layer[index] * alpha,
|
|
);
|
|
}
|
|
const foreground = rgb(style.color);
|
|
const luminance = (color) =>
|
|
color
|
|
.slice(0, 3)
|
|
.map((channel) => {
|
|
const value = channel / 255;
|
|
return value <= 0.04045
|
|
? value / 12.92
|
|
: ((value + 0.055) / 1.055) ** 2.4;
|
|
})
|
|
.reduce(
|
|
(total, value, index) =>
|
|
total + value * [0.2126, 0.7152, 0.0722][index],
|
|
0,
|
|
);
|
|
const light = luminance(foreground);
|
|
const dark = luminance(background);
|
|
const tokenColor = new Option().style;
|
|
tokenColor.color = style.getPropertyValue("--p-text-color");
|
|
return {
|
|
color: style.color,
|
|
textColor: tokenColor.color,
|
|
background: background.map(Math.round),
|
|
contrast: (Math.max(light, dark) + 0.05) / (Math.min(light, dark) + 0.05),
|
|
};
|
|
});
|
|
}
|
|
|
|
test(
|
|
"dark structured catalog descriptions keep readable field text",
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
await page.emulateMedia({ colorScheme: "dark" });
|
|
await boundary(page, {
|
|
"/product/group/first": { list: [{ id: 1, name: "云产品" }] },
|
|
"/product/group/second": {
|
|
list: [{ id: 2, parent_id: 1, name: "测试分类", description: "" }],
|
|
},
|
|
"/product": {
|
|
list: [
|
|
{
|
|
id: 123,
|
|
name: "布局验收商品",
|
|
module: "idcsmart_common",
|
|
res_module: "idcsmart_common",
|
|
pay_type: "recurring_prepayment",
|
|
price: "10.00",
|
|
billing_cycle: "monthly",
|
|
stock_control: 0,
|
|
qty: 10,
|
|
order_allowed: true,
|
|
description: "配置:4GB 内存\n线路:BGP",
|
|
},
|
|
],
|
|
count: 1,
|
|
},
|
|
});
|
|
await login(page, "/products?g=1&sg=2");
|
|
const value = page
|
|
.locator(".description-field dd")
|
|
.filter({ hasText: "4GB" });
|
|
const label = page
|
|
.locator(".description-field dt")
|
|
.filter({ hasText: "配置" });
|
|
await value.waitFor();
|
|
await label.waitFor();
|
|
assert.ok(
|
|
(await textPresentation(value)).contrast >= 4.5,
|
|
"Structured description values must remain readable in dark mode",
|
|
);
|
|
assert.ok(
|
|
(await textPresentation(label)).contrast >= 4.5,
|
|
"Structured description labels must remain readable in dark mode",
|
|
);
|
|
return { structuredDescriptionReadable: true };
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"dark withdrawal notices do not recolor the home announcement title",
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
await page.emulateMedia({ colorScheme: "dark" });
|
|
await boundary(page, {
|
|
"/announcement": {
|
|
list: [{ id: 1, title: "本地公告", create_time: 1700000000 }],
|
|
count: 1,
|
|
},
|
|
"/withdraw/rule/credit": {
|
|
status: 1,
|
|
method: [{ id: 0, name: "账户余额", no_account: true }],
|
|
min: "1.00",
|
|
max: "100.00",
|
|
withdraw_fee_type: "fixed",
|
|
withdraw_fee: "0.00",
|
|
},
|
|
});
|
|
await login(page, "/");
|
|
const announcement = page
|
|
.locator(".home-row-title")
|
|
.filter({ hasText: "本地公告" });
|
|
await announcement.waitFor();
|
|
const home = await textPresentation(announcement);
|
|
assert.equal(
|
|
home.color,
|
|
home.textColor,
|
|
"The announcement title owns its normal text color",
|
|
);
|
|
await page.getByRole("button", { name: "提现", exact: true }).click();
|
|
const dialog = page.getByRole("dialog", { name: "提现", exact: true });
|
|
const notice = dialog.locator(".notice-title");
|
|
await notice.waitFor();
|
|
const ownNotice = await textPresentation(notice);
|
|
assert.ok(
|
|
ownNotice.contrast >= 4.5,
|
|
"Withdrawal instructions must remain readable",
|
|
);
|
|
assert.notEqual(
|
|
ownNotice.color,
|
|
home.color,
|
|
"The withdrawal notice retains its own emphasis color",
|
|
);
|
|
assert.equal((await textPresentation(announcement)).color, home.color);
|
|
return {
|
|
homeAnnouncementColorOwned: true,
|
|
withdrawalNoticeReadable: true,
|
|
};
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"real-name upload fields expose distinct labels and named removal actions",
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
const requests = await boundary(page, {
|
|
"/certification/info": {
|
|
certification_open: 1,
|
|
certification_company_open: 1,
|
|
certification_upload: 1,
|
|
certification_company_need_person: 0,
|
|
is_certification: 0,
|
|
person: { status: 0 },
|
|
company: { status: 0 },
|
|
},
|
|
"/upload": { save_name: "identity-front.png" },
|
|
});
|
|
await login(page, "/account/realname/personal");
|
|
const front = page.getByLabel("身份证正面照 *", { exact: true });
|
|
const back = page.getByLabel("身份证反面照 *", { exact: true });
|
|
await front.waitFor();
|
|
await back.waitFor();
|
|
assert.notEqual(
|
|
await front.getAttribute("id"),
|
|
await back.getAttribute("id"),
|
|
);
|
|
const file = {
|
|
name: "identity-front.png",
|
|
mimeType: "image/png",
|
|
buffer: Buffer.from("local-e2e-upload-not-identity-data"),
|
|
};
|
|
await front.setInputFiles(file);
|
|
const remove = page.getByRole("button", {
|
|
name: "移除身份证正面照 *",
|
|
exact: true,
|
|
});
|
|
await remove.waitFor();
|
|
assert.deepEqual(
|
|
requests.filter((request) => request.path === "/upload"),
|
|
[{ path: "/upload", authenticated: true, multipart: true }],
|
|
);
|
|
await remove.click();
|
|
assert.equal(await remove.count(), 0);
|
|
await front.setInputFiles(file);
|
|
await remove.waitFor();
|
|
return {
|
|
separateFileInputNames: true,
|
|
namedRemoval: true,
|
|
sameFileReselection: true,
|
|
authenticatedMultipartUpload: true,
|
|
};
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"bank voucher uploads are named from the visible order heading",
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
const requests = await boundary(page, {
|
|
"/order/42": {
|
|
order: {
|
|
id: 42,
|
|
amount: "20.00",
|
|
amount_unpaid: "20.00",
|
|
credit: "0.00",
|
|
type: "new",
|
|
status: "WaitUpload",
|
|
gateway: "artificial",
|
|
create_time: 1700000000,
|
|
items: [{ id: 1, product_name: "本地测试实例", amount: "20.00" }],
|
|
},
|
|
customfields: { is_pre_invoice: 0 },
|
|
},
|
|
"/upload": { save_name: "bank-voucher.pdf" },
|
|
});
|
|
await login(page, "/orders/42");
|
|
await page
|
|
.getByRole("heading", { name: "银行转账凭证", exact: true })
|
|
.waitFor();
|
|
const input = page.locator("input[type=file]");
|
|
await input.waitFor();
|
|
const accessible = await input.ariaSnapshot();
|
|
assert.ok(
|
|
accessible.includes("银行转账凭证"),
|
|
`The visible voucher heading must name the upload field; browser accessibility tree: ${accessible}`,
|
|
);
|
|
await page.getByLabel("银行转账凭证", { exact: true }).setInputFiles({
|
|
name: "bank-voucher.pdf",
|
|
mimeType: "application/pdf",
|
|
buffer: Buffer.from("local-e2e-voucher-not-financial-data"),
|
|
});
|
|
const remove = page.getByRole("button", {
|
|
name: "移除凭证",
|
|
exact: true,
|
|
});
|
|
await remove.waitFor();
|
|
assert.deepEqual(
|
|
requests.filter((request) => request.path === "/upload"),
|
|
[{ path: "/upload", authenticated: true, multipart: true }],
|
|
);
|
|
await remove.click();
|
|
assert.equal(await remove.count(), 0);
|
|
return {
|
|
visibleHeadingNamesUpload: true,
|
|
authenticatedMultipartUpload: true,
|
|
localVoucherRemoval: true,
|
|
};
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"normal kky upgrade plans and disks fit the narrow dialog container",
|
|
{ timeout: 30000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
await boundary(page, {
|
|
"/host/5": {
|
|
host: {
|
|
id: 5,
|
|
product_id: 123,
|
|
module: "kky_ecs",
|
|
name: "本地弹性云",
|
|
status: "Active",
|
|
billing_cycle: "monthly",
|
|
upgrade_allowed: true,
|
|
renew_amount: "10.00",
|
|
},
|
|
},
|
|
"/kky_ecs/5": {
|
|
cpu: 2,
|
|
memory: 4,
|
|
bw: 10,
|
|
protect: 0,
|
|
ip: "192.0.2.5",
|
|
username: "root",
|
|
port: 22,
|
|
image: { name: "Ubuntu 24.04" },
|
|
},
|
|
"/kky_ecs/5/status": { status: "on", rescue: false },
|
|
"/kky_ecs/5/traffic": {
|
|
mode: "bandwidth",
|
|
monthly_quota: 100,
|
|
traffic_show: 0,
|
|
},
|
|
"/kky_ecs/disk/5": {
|
|
list: [
|
|
{
|
|
id: "data-1",
|
|
name: "数据盘",
|
|
size: 40,
|
|
type2: "data",
|
|
fs_type: "SSD",
|
|
},
|
|
],
|
|
},
|
|
"/kky_ecs/monitor/5": {
|
|
sample_info: [],
|
|
cpu: [],
|
|
memory: [],
|
|
bandwidth: [],
|
|
disk: [],
|
|
},
|
|
"/host/5/renew/auto": { status: 0 },
|
|
"/refund": { allow_refund: 0, reasons: [] },
|
|
"/refund/host/5/refund": { refund: null },
|
|
"/promo_code/host/5/promo_code": { promo_code: [] },
|
|
"/kky_ecs/upgrade/5/ins_upgrade_config": [
|
|
{
|
|
cpu_num: 4,
|
|
mem_list: [
|
|
{ id: 1, memory: 8, is_sell_out: 0, description: "常规升级规格" },
|
|
],
|
|
},
|
|
],
|
|
"/kky_ecs/upgrade/5/add_disk_config": {
|
|
min: 10,
|
|
max: 1000,
|
|
step: 10,
|
|
type: ["SSD"],
|
|
remaining_num: 0,
|
|
},
|
|
});
|
|
await login(page, "/servers/server?id=5");
|
|
await page.getByRole("tab", { name: "计费", exact: true }).click();
|
|
await page.getByRole("button", { name: "升降级", exact: true }).click();
|
|
const dialog = page.getByRole("dialog", { name: "升降级", exact: true });
|
|
await dialog.locator(".plans .plan").waitFor();
|
|
const widths = [];
|
|
const assertFits = async (gridName, cardName) => {
|
|
const measured = await dialog
|
|
.locator(gridName)
|
|
.evaluate((grid, childSelector) => {
|
|
const bounds = grid.getBoundingClientRect();
|
|
const card = grid
|
|
.querySelector(childSelector)
|
|
.getBoundingClientRect();
|
|
return {
|
|
width: bounds.width,
|
|
scroll: grid.scrollWidth,
|
|
client: grid.clientWidth,
|
|
rightOverflow: card.right - bounds.right,
|
|
};
|
|
}, cardName);
|
|
assert.ok(measured.width > 0);
|
|
assert.ok(
|
|
measured.rightOverflow <= 1 && measured.scroll <= measured.client + 1,
|
|
`${gridName} must shrink to its actual dialog container: ${JSON.stringify(measured)}`,
|
|
);
|
|
widths.push({
|
|
viewport: page.viewportSize().width,
|
|
grid: gridName,
|
|
width: Math.round(measured.width),
|
|
fits: true,
|
|
});
|
|
};
|
|
for (const width of [375, 320]) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
await assertFits(".plans", ".plan");
|
|
}
|
|
await dialog.getByRole("button", { name: /硬盘/ }).click();
|
|
await dialog
|
|
.getByRole("button", { name: "扩容已有盘", exact: true })
|
|
.click();
|
|
await dialog.locator(".disks .disk").waitFor();
|
|
for (const width of [375, 320]) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
await assertFits(".disks", ".disk");
|
|
}
|
|
return { grids: widths };
|
|
}),
|
|
);
|