/** 主题和字段回归经真实商品、首页、实名认证与实例入口验证;只替换外部 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 }; }), );