mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ feat(webchat): применять оформление кнопки из конфигурации
This commit is contained in:
1 parent
223a962cb9
commit
b160335bf6
17 files changed
+829
-400
No files matched your search
@@ -0,0 +1,34 @@
|
||||
# Проверка лоадера
|
||||
|
||||
Модульные тесты разбора оформления и геометрии анимации:
|
||||
|
||||
```sh
|
||||
node --test tests/webchat/loader.test.cjs
|
||||
```
|
||||
|
||||
Связанные серверные тесты запускаются в `backend-admin` штатного dev-контура,
|
||||
с `--reuse-db`:
|
||||
|
||||
```sh
|
||||
pytest -q chatballs/webchat/test_appearance.py::WebAppearanceApiTests \
|
||||
chatballs/webchat/tests.py::PublicWebChatWidgetTests --reuse-db
|
||||
```
|
||||
|
||||
Для браузерной проверки открыть `/chat/demo.html?widgetKey=<существующий ключ>`.
|
||||
Параметр `widgetOrigin` позволяет проверить вставку с другого origin:
|
||||
`/chat/demo.html?widgetKey=<ключ>&widgetOrigin=<адрес установки>`.
|
||||
Оба адреса страницы должны быть разрешены в настройках существующего подключения.
|
||||
|
||||
`check-launcher.js` выполняется через task-local Playwright MCP
|
||||
(`browser_run_code_unsafe`, `filename`). Сценарий читает реальную конфигурацию,
|
||||
проверяет размер, форму, сторону, цвет, тень, фокус, иконку, открытие/закрытие
|
||||
«джина», разворачивание окна и полноэкранный режим 390×844.
|
||||
Запустить для размеров 48/56/64 с обеих сторон, меняя настройки существующего
|
||||
dev-подключения и восстанавливая их после проверки. Сценарий не создаёт данные
|
||||
и не начинает диалог.
|
||||
|
||||
`check-fallback.js` проверяет стандартную кнопку и работу страницы при сетевом
|
||||
отказе запроса конфигурации. Он прерывает запрос, не подставляя ответ API.
|
||||
Отдельно проверить отказ загрузки иконки и получение конфигурации во время
|
||||
анимации: при ошибке иконки возвращается стандартный знак, поздние настройки
|
||||
применяются после завершения анимации.
|
||||
@@ -0,0 +1,29 @@
|
||||
// Проверяем сетевой отказ, не подменяя API вымышленным ответом.
|
||||
async (page) => {
|
||||
const errors = [];
|
||||
const onError = error => errors.push(error.message);
|
||||
const endpoint = "**/api/v1/webchat/config/**";
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
page.on("pageerror", onError);
|
||||
await page.route(endpoint, route => route.abort("failed"));
|
||||
try {
|
||||
const failed = page.waitForEvent("requestfailed", { predicate: request => request.url().includes("/webchat/config/") });
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await failed;
|
||||
const button = page.locator(".chatballs-chat-launcher");
|
||||
const defaults = await button.evaluate(b => ({ color: getComputedStyle(b).backgroundColor,
|
||||
width: b.offsetWidth, radius: b.style.borderRadius, side: b.style.right, standardIcon: !!b.querySelector("svg") }));
|
||||
if (defaults.color !== "rgb(22, 119, 255)" || defaults.width !== 56 || defaults.radius !== "50%" ||
|
||||
defaults.side !== "24px" || !defaults.standardIcon) throw new Error("Default launcher was lost");
|
||||
await button.click();
|
||||
await page.waitForFunction(() => document.querySelector("iframe").parentElement.style.clipPath === "none");
|
||||
await button.click();
|
||||
await page.waitForFunction(() => document.querySelector("iframe").parentElement.style.display === "none");
|
||||
if (errors.length) throw new Error(errors.join("; "));
|
||||
if (!await page.getByRole("heading").isVisible()) throw new Error("Host page was affected");
|
||||
return { defaults, pageErrors: errors, openCloseWorks: true, passed: true };
|
||||
} finally {
|
||||
await page.unroute(endpoint);
|
||||
page.off("pageerror", onError);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Сценарий для task-local Playwright MCP на штатной demo.html?widgetKey=… .
|
||||
// Конфигурация берётся с реального API; ответы и учётные записи не подменяются.
|
||||
async (page) => {
|
||||
const check = (condition, message) => { if (!condition) throw new Error(message); };
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
const config = await page.evaluate(async () => {
|
||||
const key = new URLSearchParams(location.search).get("widgetKey");
|
||||
const response = await fetch("/api/v1/webchat/config/?widgetKey=" + encodeURIComponent(key) +
|
||||
"&hostOrigin=" + encodeURIComponent(location.origin));
|
||||
return response.json();
|
||||
});
|
||||
check(config.available, "The real widget must be available");
|
||||
const { launcherPosition: side, launcherSize: size, launcherShape: shape, accent, launcherIcon } = config.appearance;
|
||||
const rgb = [1, 3, 5].map(offset => parseInt(accent.slice(offset, offset + 2), 16)).join(", ");
|
||||
const fill = `rgb(${rgb.replaceAll(" ", "")})`;
|
||||
await page.waitForFunction(({ side, size }) => {
|
||||
const b = document.querySelector(".chatballs-chat-launcher");
|
||||
return b && b.style[side] === "24px" && b.offsetWidth === size;
|
||||
}, { side, size });
|
||||
const button = page.locator(".chatballs-chat-launcher");
|
||||
await page.mouse.move(640, 850);
|
||||
await page.waitForFunction(rgb => getComputedStyle(document.querySelector(".chatballs-chat-launcher"))
|
||||
.boxShadow.includes(`rgba(${rgb}, 0.35)`), rgb);
|
||||
const read = () => button.evaluate(b => ({ box: b.getBoundingClientRect().toJSON(),
|
||||
color: getComputedStyle(b).backgroundColor, radius: getComputedStyle(b).borderRadius,
|
||||
shadow: getComputedStyle(b).boxShadow, outline: getComputedStyle(b).outlineColor,
|
||||
icon: b.querySelector("img")?.src, iconWidth: b.querySelector("img")?.naturalWidth }));
|
||||
if (launcherIcon) await page.waitForFunction(() => document.querySelector(".chatballs-chat-launcher img")?.naturalWidth > 0);
|
||||
const launcher = await read();
|
||||
check(launcher.color === `rgb(${rgb})`, "Accent was not applied");
|
||||
check(launcher.box.width === size && launcher.box.height === size, "Incorrect launcher size");
|
||||
check(launcher.radius === ({ circle: "50%", rounded: "30%", square: "10px" })[shape], "Incorrect launcher shape");
|
||||
check(side === "left" ? launcher.box.left === 24 : launcher.box.right === 1256, "Incorrect launcher side");
|
||||
await page.keyboard.press("Tab");
|
||||
await button.focus();
|
||||
const focus = await read();
|
||||
check(focus.outline === `rgba(${rgb}, 0.45)`, "Focus must use accent");
|
||||
await button.hover();
|
||||
await page.waitForFunction(rgb => getComputedStyle(document.querySelector(".chatballs-chat-launcher"))
|
||||
.boxShadow.includes(`rgba(${rgb}, 0.45)`), rgb);
|
||||
const hover = await read();
|
||||
await page.mouse.move(640, 850);
|
||||
const animate = () => page.evaluate(() => new Promise(resolve => {
|
||||
document.querySelector(".chatballs-chat-launcher").click();
|
||||
const svg = document.querySelector("body > svg");
|
||||
const path = svg.querySelector("path");
|
||||
const samples = [];
|
||||
const sample = () => {
|
||||
samples.push({ fill: path.getAttribute("fill"), path: path.getAttribute("d") });
|
||||
if (svg.style.display === "none") resolve(samples);
|
||||
else requestAnimationFrame(sample);
|
||||
};
|
||||
sample();
|
||||
}));
|
||||
const opening = await animate();
|
||||
check(opening.length > 2 && opening[0].fill === fill, "Genie must start in the launcher accent");
|
||||
check(opening.every(s => !/NaN|Infinity/.test(s.path)), "Genie geometry must stay finite");
|
||||
const panel = () => page.locator("iframe").evaluate(f => f.parentElement.getBoundingClientRect().toJSON());
|
||||
const normal = await panel();
|
||||
check(normal.bottom === 900 - size - 36, "Panel must stay 12px above launcher");
|
||||
check(side === "left" ? normal.left === 24 : normal.right === 1256, "Panel must follow launcher side");
|
||||
const frame = page.frameLocator("iframe");
|
||||
await frame.getByRole("button", { name: "Развернуть окно", exact: true }).click();
|
||||
await page.waitForFunction(() => Math.abs(document.querySelector("iframe").parentElement.getBoundingClientRect().width - 820) < .1);
|
||||
const expanded = await panel();
|
||||
check(side === "left" ? expanded.left === 24 : expanded.right === 1256, "Expanded panel must keep its side");
|
||||
const closing = await animate();
|
||||
check(closing.at(-1).fill === fill, "Genie must close into the launcher accent");
|
||||
if (launcherIcon) check((await read()).iconWidth > 0, "Custom icon must return after closing");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await animate();
|
||||
const mobile = await panel();
|
||||
check(mobile.x === 0 && mobile.y === 0 && mobile.width === 390 && mobile.height === 844, "Mobile panel must be fullscreen");
|
||||
check(await button.evaluate(b => b.style.opacity === "0" && b.style.pointerEvents === "none"), "Launcher must be hidden on mobile");
|
||||
check(await frame.getByRole("button", { name: "Развернуть окно", exact: true }).count() === 0, "Fullscreen panel cannot expand");
|
||||
await frame.getByRole("button", { name: "Свернуть", exact: true }).click();
|
||||
await page.waitForFunction(() => document.querySelector("iframe").parentElement.style.display === "none");
|
||||
check(await button.evaluate(b => b.style.opacity === "1" && b.style.pointerEvents !== "none"), "Launcher must return after mobile close");
|
||||
return { side, size, shape, accent, launcher, focus: focus.outline, hover: hover.shadow,
|
||||
normal, expanded, mobile, genieFrames: [opening.length, closing.length], passed: true };
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { readFileSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
const { test } = require("node:test");
|
||||
const vm = require("node:vm");
|
||||
|
||||
const assets = join(__dirname, "../../apps/backend/chatballs/webchat/loader_assets");
|
||||
function context() {
|
||||
const ctx = vm.createContext({ URL, origin: "https://widget.example" });
|
||||
vm.runInContext(readFileSync(join(assets, "appearance.js"), "utf8"), ctx);
|
||||
vm.runInContext(readFileSync(join(assets, "genie.js"), "utf8"), ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
test("launcher configuration covers both sides, all sizes and all shapes", () => {
|
||||
const ctx = context();
|
||||
for (const position of ["left", "right"]) {
|
||||
for (const size of [48, 56, 64]) {
|
||||
for (const shape of ["circle", "rounded", "square"]) {
|
||||
ctx.appearance = ctx.readAppearance({ available: true, appearance: {
|
||||
accent: "#0d8a7e", launcherPosition: position, launcherSize: size, launcherShape: shape,
|
||||
launcherIcon: "/api/v1/webchat/assets/icon/",
|
||||
} });
|
||||
assert.equal(ctx.appearance.position, position);
|
||||
assert.equal(ctx.appearance.size, size);
|
||||
assert.equal(ctx.appearance.shape, shape);
|
||||
assert.deepEqual(Array.from(ctx.appearance.rgb), [13, 138, 126]);
|
||||
assert.equal(ctx.appearance.icon, "https://widget.example/api/v1/webchat/assets/icon/");
|
||||
assert.equal(ctx.launcherRadius(), shape === "square" ? 10 : size * (shape === "rounded" ? .3 : .5));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy accent works and absent configuration leaves the default launcher", () => {
|
||||
const ctx = context();
|
||||
const legacy = ctx.readAppearance({ available: true, accent: "#BE123C" });
|
||||
assert.deepEqual(Array.from(legacy.rgb), [190, 18, 60]);
|
||||
assert.equal(legacy.position, "right");
|
||||
assert.equal(legacy.size, 56);
|
||||
assert.equal(legacy.shape, "circle");
|
||||
for (const response of [null, {}, { available: false }, { available: false, appearance: {} }]) {
|
||||
assert.equal(ctx.readAppearance(response), null);
|
||||
}
|
||||
assert.equal(ctx.appearance.accent, "#1677ff");
|
||||
});
|
||||
|
||||
test("invalid configuration is rejected before any appearance is applied", () => {
|
||||
const ctx = context();
|
||||
for (const appearance of [null, [], "text", { accent: "red" }, { launcherSize: 50 },
|
||||
{ launcherSize: true }, { launcherPosition: "top" }, { launcherShape: "oval" },
|
||||
{ launcherIcon: 42 }, { launcherIcon: "javascript:alert(1)" },
|
||||
{ launcherIcon: "//evil.example/icon.svg" }, { launcherIcon: "data:image/svg+xml,<svg/>" },
|
||||
{ launcherIcon: '/icon.svg" onerror="alert(1)' }]) {
|
||||
assert.throws(() => ctx.readAppearance({ available: true, appearance }));
|
||||
assert.equal(ctx.appearance.accent, "#1677ff");
|
||||
}
|
||||
});
|
||||
|
||||
test("genie geometry mirrors across the viewport and ends in the selected launcher shape", () => {
|
||||
const ctx = context();
|
||||
const viewport = 1280;
|
||||
const panel = { top: 100, bottom: 708, cx: 1036, half: 220, radius: 24 };
|
||||
for (const size of [48, 56, 64]) {
|
||||
for (const radius of [size / 2, size * .3, 10]) {
|
||||
const icon = { top: 720, bottom: 720 + size, cx: 1224, half: size / 2, radius };
|
||||
for (const p of [0, .25, .5, .75, 1]) {
|
||||
const right = ctx.shapeAt(p, panel, icon);
|
||||
const left = ctx.shapeAt(p, { ...panel, cx: viewport - panel.cx }, { ...icon, cx: viewport - icon.cx });
|
||||
assert.ok(Math.abs(left.topL - (viewport - right.topRt)) < 1e-9);
|
||||
assert.ok(Math.abs(left.bottomRt - (viewport - right.bottomL)) < 1e-9);
|
||||
assert.equal(left.topY, right.topY);
|
||||
assert.equal(left.bottomY, right.bottomY);
|
||||
assert.ok(!/NaN|Infinity/.test(ctx.pathOf(left, 0, 0)));
|
||||
if (p === 1) {
|
||||
assert.equal(left.topR, radius);
|
||||
assert.equal(left.bottomR, radius);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("all loader modules assemble into one valid standalone script", () => {
|
||||
let source = readFileSync(join(assets, "runtime.js"), "utf8");
|
||||
for (const module of ["appearance", "launcher", "genie"]) {
|
||||
source = source.replace(`/*__${module.toUpperCase()}__*/`, () => readFileSync(join(assets, `${module}.js`), "utf8"));
|
||||
}
|
||||
assert.doesNotThrow(() => new vm.Script(source));
|
||||
});
|
||||
Reference in new issue
Block a user