mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
✨ feat(webchat): передавать поля сайта через лоадер и виджет
This commit is contained in:
1 parent
b160335bf6
commit
29e2232fc3
13 files changed
+360
-27
No files matched your search
@@ -0,0 +1,32 @@
|
||||
---
|
||||
id: T-013
|
||||
title: Chatballs.setFields в лоадере и передача полей из виджета
|
||||
milestone: M02
|
||||
status: done
|
||||
depends_on:
|
||||
- T-010
|
||||
order: 5
|
||||
spec: "0019"
|
||||
created: 2026-09-29
|
||||
branch: skaro/T-013-chatballs-setfields-v-loadere
|
||||
---
|
||||
|
||||
## Цель
|
||||
|
||||
Сайт может вызывать Chatballs.setFields() в любой момент, даже до загрузки скрипта, и значения доходят до сервера (R-5, R-6, R-7).
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- [x] window.Chatballs с очередью q и методом setFields; вызовы до загрузки лоадера обрабатываются; window.ChatballsChat продолжает работать
|
||||
- [x] Лоадер передаёт поля в свой iframe через postMessage({type:"chatballs-set-fields", fields}) с проверкой origin
|
||||
- [x] Виджет сливает вызовы по ключам, null очищает; отправка не чаще раза в 500 мс
|
||||
- [x] До старта сессии поля держатся в памяти и уходят вместе с startSession, после — POST /webchat/fields/
|
||||
- [x] Сбой отправки не ломает виджет и страницу сайта
|
||||
|
||||
## Заметки
|
||||
|
||||
webchat/loader.py (LOADER_JS), web-chat/src/api.ts, App.tsx. Не раздувать App.tsx — вынести в отдельный модуль/хук.
|
||||
|
||||
## Итог
|
||||
|
||||
Ветка перенесена через git rebase main на b160335b; конфликт loader.py разрешён с сохранением оформления кнопки и модульной структуры из main. Передача полей встроена в loader_assets, дублирующие loader_scripts удалены; итоговый коммит e4f25642, рабочее дерево чистое. После разрешения конфликта прошли сборка, 6 Vitest-тестов передачи полей, 5 Node-тестов лоадера, Ruff, Django check и браузерная проверка реального dev-контура задачи.
|
||||
@@ -130,6 +130,17 @@ After creating a web widget, add one tag to your site:
|
||||
|
||||
The chat opens in an isolated window on top of the site.
|
||||
|
||||
Pass visitor data with `Chatballs.setFields()`. To call it before the asynchronous script loads, declare a queue before the widget script tag:
|
||||
|
||||
```html
|
||||
<script>
|
||||
window.Chatballs = window.Chatballs || { q: [], setFields: function (fields) { this.q.push(fields); } };
|
||||
Chatballs.setFields({ name: "Ivan", email: "ivan@example.com" });
|
||||
</script>
|
||||
```
|
||||
|
||||
Each call updates only the supplied keys; `null` clears a value. Add custom fields in the web integration settings first. The widget combines updates and sends them at most once every 500 ms. Values are never used to authorize a customer.
|
||||
|
||||
### Calls
|
||||
|
||||
Calls work right after the installation. Between browsers the conversation goes directly; when one side sits behind strict NAT or on a VPN it goes through the relay, which starts together with the stack on the same address. Nothing to configure: the relay addresses appear in **Settings → TURN for calls** on their own, derived from the installation address, and are only changed if you run your own server.
|
||||
|
||||
@@ -130,6 +130,17 @@ docker compose up -d --wait
|
||||
|
||||
Чат откроется в изолированном окне поверх сайта.
|
||||
|
||||
Данные посетителя передаются через `Chatballs.setFields()`. Чтобы вызвать его до загрузки асинхронного скрипта, объявите очередь перед тегом подключения:
|
||||
|
||||
```html
|
||||
<script>
|
||||
window.Chatballs = window.Chatballs || { q: [], setFields: function (fields) { this.q.push(fields); } };
|
||||
Chatballs.setFields({ name: "Иван", email: "ivan@example.com" });
|
||||
</script>
|
||||
```
|
||||
|
||||
Каждый вызов обновляет только переданные ключи; `null` очищает значение. Свои поля сначала добавьте в настройках веб-подключения. Виджет объединяет обновления и отправляет их не чаще раза в 500 мс. Значения не используются для авторизации клиента.
|
||||
|
||||
### Звонки
|
||||
|
||||
Звонки работают сразу после установки. Между браузерами разговор идёт напрямую, а если одна из сторон за строгим NAT или в VPN — через relay, который поднимается вместе со стеком на том же адресе. Настраивать нечего: адреса relay появляются в **Настройки → TURN для звонков** сами, от адреса установки, и меняются только если вы ставите свой сервер.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Публичный автономный JS-лоадер: оформление, панель и анимация джина.
|
||||
"""Публичный автономный JS-лоадер: поля сайта, оформление, панель и анимация джина.
|
||||
|
||||
Модули собираются в одну IIFE; дополнительных запросов за JS на сайте нет.
|
||||
"""
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
|
||||
_ASSETS = Path(__file__).with_name("loader_assets")
|
||||
LOADER_JS = (_ASSETS / "runtime.js").read_text(encoding="utf-8")
|
||||
for _module in ("appearance", "launcher", "genie"):
|
||||
for _module in ("site_fields", "appearance", "launcher", "genie"):
|
||||
LOADER_JS = LOADER_JS.replace(
|
||||
f"/*__{_module.upper()}__*/",
|
||||
(_ASSETS / f"{_module}.js").read_text(encoding="utf-8"),
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
var api = window.ChatballsChat = window.ChatballsChat || {};
|
||||
window.ChatballsChat = api; // legacy alias для уже встроенных хостов
|
||||
|
||||
/*__SITE_FIELDS__*/
|
||||
|
||||
/*__APPEARANCE__*/
|
||||
|
||||
/*__LAUNCHER__*/
|
||||
@@ -42,6 +44,10 @@
|
||||
if (e.origin !== origin || !frame || e.source !== frame.contentWindow) return;
|
||||
var d = e.data || {};
|
||||
if (d.instanceId && d.instanceId !== instanceId) return;
|
||||
if (d.type === "chatballs-fields-ready") {
|
||||
fieldsReady = true;
|
||||
sendFields();
|
||||
}
|
||||
if (d.type === "chatballs-chat-close") setOpen(false);
|
||||
if (d.type === "chatballs-chat-layout-request") notifyLayout();
|
||||
if (d.type === "chatballs-chat-expand") setExpanded(Boolean(d.expanded));
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// До загрузки скрипта сайт может объявить:
|
||||
// window.Chatballs = { q: [], setFields: function (fields) { this.q.push(fields); } };
|
||||
var fieldsApi = window.Chatballs = window.Chatballs || {};
|
||||
var queuedFields = Array.isArray(fieldsApi.q) ? fieldsApi.q.slice() : [];
|
||||
var siteFields = Object.create(null), fieldsReady = false;
|
||||
fieldsApi.q = [];
|
||||
|
||||
function sendFields() {
|
||||
if (!fieldsReady || !frame || !frame.contentWindow) return;
|
||||
try {
|
||||
frame.contentWindow.postMessage({ type: "chatballs-set-fields", fields: siteFields }, origin);
|
||||
} catch (_) {} // Ошибка данных/окна не должна ломать страницу сайта.
|
||||
}
|
||||
|
||||
fieldsApi.setFields = function (fields) {
|
||||
try {
|
||||
if (!fields || typeof fields !== "object" || Array.isArray(fields)) return;
|
||||
Object.keys(fields).forEach(function (key) {
|
||||
var value = fields[key];
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean" ||
|
||||
(typeof value === "number" && isFinite(value))) siteFields[key] = value;
|
||||
});
|
||||
sendFields();
|
||||
} catch (_) {}
|
||||
};
|
||||
queuedFields.forEach(function (entry) {
|
||||
// Поддерживаем очередь объектов и обычный stub с q.push(["setFields", fields]).
|
||||
fieldsApi.setFields(Array.isArray(entry) && entry[0] === "setFields" ? entry[1] : entry);
|
||||
});
|
||||
@@ -67,16 +67,27 @@ export async function getConfig(entry: WidgetEntry, hostOrigin: string): Promise
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function startSession(entry: WidgetEntry, hostOrigin: string): Promise<string | null> {
|
||||
export type SiteFields = Record<string, string | number | boolean | null>;
|
||||
|
||||
export async function startSession(entry: WidgetEntry, hostOrigin: string, fields: SiteFields = {}): Promise<string | null> {
|
||||
const r = await fetch(`${API}/session/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...entry, hostOrigin }),
|
||||
body: JSON.stringify({ ...entry, hostOrigin, fields }),
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
return (await r.json()).token as string;
|
||||
}
|
||||
|
||||
export async function sendSiteFields(token: string, fields: SiteFields): Promise<boolean> {
|
||||
const r = await fetch(`${API}/fields/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ fields }),
|
||||
});
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
export async function sendMessage(token: string, text: string): Promise<boolean> {
|
||||
const r = await fetch(`${API}/messages/`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SiteFields } from "./api";
|
||||
import { SiteFieldsSender } from "./siteFields";
|
||||
|
||||
describe("site fields delivery", () => {
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
function setup(sendResult: () => Promise<boolean> = async () => true) {
|
||||
const calls: { token: string; fields: SiteFields; time: number }[] = [];
|
||||
const sender = new SiteFieldsSender(async (token, fields) => {
|
||||
calls.push({ token, fields, time: Date.now() });
|
||||
return sendResult();
|
||||
});
|
||||
sender.resume();
|
||||
return { sender, calls };
|
||||
}
|
||||
|
||||
it("merges in memory before start and sends changes arriving during start", async () => {
|
||||
const { sender, calls } = setup();
|
||||
sender.merge({ name: "Иван", status: "cooking" });
|
||||
sender.merge({ status: null, amount: 0, has_order: false });
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(calls).toEqual([]);
|
||||
let resolve!: (token: string) => void;
|
||||
let initial: SiteFields = {};
|
||||
const start = sender.start((fields) => {
|
||||
initial = fields;
|
||||
return new Promise<string>((done) => { resolve = done; });
|
||||
});
|
||||
expect(initial).toEqual({ name: "Иван", status: null, amount: 0, has_order: false });
|
||||
sender.merge({ status: "delivered" });
|
||||
resolve("session");
|
||||
expect(await start).toBe("session");
|
||||
await vi.advanceTimersByTimeAsync(499);
|
||||
expect(calls).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(calls[0]).toMatchObject({ token: "session", fields: { status: "delivered" } });
|
||||
});
|
||||
|
||||
it("coalesces updates and sends at most once every 500 ms without overlapping", async () => {
|
||||
let finish!: (ok: boolean) => void;
|
||||
const { sender, calls } = setup(() => new Promise<boolean>((done) => { finish = done; }));
|
||||
sender.setToken("restored");
|
||||
sender.merge({ status: "cooking" });
|
||||
sender.merge({ status: "on_the_way", name: "Иван" });
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(calls[0].fields).toEqual({ status: "on_the_way", name: "Иван" });
|
||||
sender.merge({ status: null });
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(calls).toHaveLength(1);
|
||||
finish(true);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(calls[1].fields).toEqual({ status: null });
|
||||
expect(calls[1].time - calls[0].time).toBeGreaterThanOrEqual(500);
|
||||
finish(true);
|
||||
});
|
||||
|
||||
it.each(["rejection", "http failure"])("retains latest fields after %s and retries", async (failure) => {
|
||||
let attempts = 0;
|
||||
const { sender, calls } = setup(async () => {
|
||||
if (++attempts === 1) {
|
||||
if (failure === "rejection") throw new Error("offline");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
sender.setToken("session");
|
||||
sender.merge({ status: "cooking", name: "Иван" });
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
sender.merge({ status: "delivered" });
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(calls[1].fields).toEqual({ status: "delivered", name: "Иван" });
|
||||
sender.merge({ status: "delivered" });
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps fields after failed start and replays them for a new session", async () => {
|
||||
const { sender } = setup();
|
||||
sender.merge({ name: "Иван" });
|
||||
const starts: number[] = [];
|
||||
expect(await sender.start(async () => { starts.push(Date.now()); throw new Error("offline"); })).toBeNull();
|
||||
const retry = sender.start(async (fields) => {
|
||||
starts.push(Date.now());
|
||||
expect(fields).toEqual({ name: "Иван" });
|
||||
return "session";
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(499);
|
||||
expect(starts).toHaveLength(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(await retry).toBe("session");
|
||||
expect(starts[1] - starts[0]).toBe(500);
|
||||
sender.setToken(null);
|
||||
const restart = sender.start(async (fields) => {
|
||||
expect(fields).toEqual({ name: "Иван" });
|
||||
return "new-session";
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await restart;
|
||||
});
|
||||
|
||||
it("ignores malformed fields and cancels delivery on unmount", async () => {
|
||||
const { sender, calls } = setup();
|
||||
sender.setToken("session");
|
||||
sender.merge(null);
|
||||
sender.merge([]);
|
||||
sender.merge({ invalid: {}, amount: Infinity, constructor: "value", name: "Иван" });
|
||||
sender.pause();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(calls).toEqual([]);
|
||||
sender.resume();
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(calls[0].fields).toEqual({ constructor: "value", name: "Иван" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { SiteFields } from "./api";
|
||||
|
||||
const INTERVAL_MS = 500;
|
||||
|
||||
/** Поля живут в памяти; запросы сериализованы, включая старт сессии. */
|
||||
export class SiteFieldsSender {
|
||||
private values: SiteFields = {};
|
||||
private pending: SiteFields = {};
|
||||
private token: string | null = null;
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
private busy = false;
|
||||
private active = false;
|
||||
private lastSent = -Infinity;
|
||||
private retryDelay = INTERVAL_MS;
|
||||
|
||||
constructor(private readonly send: (token: string, fields: SiteFields) => Promise<boolean>) {}
|
||||
|
||||
resume() { this.active = true; this.schedule(); }
|
||||
pause() { this.active = false; clearTimeout(this.timer); this.timer = undefined; }
|
||||
|
||||
setToken(token: string | null) {
|
||||
if (this.token === token) return;
|
||||
this.token = token;
|
||||
if (!token) this.pending = { ...this.values };
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
merge(fields: unknown) {
|
||||
if (!fields || typeof fields !== "object" || Array.isArray(fields)) return;
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (!/^[a-z][a-z0-9_]{0,39}$/.test(key)) continue;
|
||||
if (value !== null && typeof value !== "string" && typeof value !== "boolean"
|
||||
&& (typeof value !== "number" || !Number.isFinite(value))) continue;
|
||||
if (Object.hasOwn(this.values, key) && this.values[key] === value) continue;
|
||||
this.values[key] = value;
|
||||
this.pending[key] = value;
|
||||
}
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
async start(issue: (fields: SiteFields) => Promise<string | null>): Promise<string | null> {
|
||||
if (this.busy) return null;
|
||||
this.busy = true;
|
||||
const wait = this.lastSent + INTERVAL_MS - Date.now();
|
||||
if (wait > 0) await new Promise<void>((resolve) => setTimeout(resolve, wait));
|
||||
const fields = { ...this.values };
|
||||
this.pending = {};
|
||||
this.lastSent = Date.now();
|
||||
try {
|
||||
const token = await issue(fields);
|
||||
if (token) this.token = token;
|
||||
else this.pending = { ...fields, ...this.pending };
|
||||
return token;
|
||||
} catch {
|
||||
this.pending = { ...fields, ...this.pending };
|
||||
return null;
|
||||
} finally {
|
||||
this.busy = false;
|
||||
this.schedule();
|
||||
}
|
||||
}
|
||||
|
||||
private schedule(delay = INTERVAL_MS) {
|
||||
if (!this.active || !this.token || this.busy || this.timer !== undefined
|
||||
|| !Object.keys(this.pending).length) return;
|
||||
const wait = Math.max(delay, this.lastSent + INTERVAL_MS - Date.now());
|
||||
this.timer = setTimeout(() => { this.timer = undefined; void this.flush(); }, wait);
|
||||
}
|
||||
|
||||
private async flush() {
|
||||
if (!this.active || !this.token || this.busy) return;
|
||||
const token = this.token;
|
||||
const fields = this.pending;
|
||||
this.pending = {};
|
||||
this.busy = true;
|
||||
this.lastSent = Date.now();
|
||||
let ok = false;
|
||||
try { ok = await this.send(token, fields); } catch { /* сеть не влияет на чат */ }
|
||||
if (!ok && this.token === token) {
|
||||
this.pending = { ...fields, ...this.pending };
|
||||
this.retryDelay = Math.min(this.retryDelay * 2, 30_000);
|
||||
} else this.retryDelay = INTERVAL_MS;
|
||||
this.busy = false;
|
||||
this.schedule(this.retryDelay);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect, type RefObject } from "react";
|
||||
import { poll, SessionExpired, type Poll } from "./api";
|
||||
|
||||
export function useChatPolling(token: string | null, lastId: RefObject<number>, ready: RefObject<boolean>, ingest: (data: Poll, notify: boolean) => void, forget: () => void) {
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
let alive = true;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const data = await poll(token, lastId.current);
|
||||
if (alive) {
|
||||
ingest(data, ready.current);
|
||||
ready.current = true;
|
||||
}
|
||||
} catch (error) {
|
||||
if (alive && error instanceof SessionExpired) forget();
|
||||
// Сеть или лимит: продолжаем опрашивать.
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
const timer = setInterval(tick, 2500);
|
||||
return () => { alive = false; clearInterval(timer); };
|
||||
}, [token]);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
getConfig,
|
||||
openWebchatCall,
|
||||
poll,
|
||||
SessionExpired,
|
||||
sendContact,
|
||||
sendMessage,
|
||||
sendFile,
|
||||
@@ -17,6 +16,8 @@ import {
|
||||
type WebMessage,
|
||||
} from "./api";
|
||||
import { useScrollToLatest } from "./useScrollToLatest";
|
||||
import { useSiteFields } from "./useSiteFields";
|
||||
import { useChatPolling } from "./useChatPolling";
|
||||
import { useVoiceRecorder } from "./useVoiceRecorder";
|
||||
import { useWidgetActivity } from "./widgetActivity";
|
||||
import { applyWidgetLanguage, t } from "./i18n";
|
||||
@@ -32,6 +33,7 @@ export function useChatSession() {
|
||||
const [config, setConfig] = useState<WebConfig | null>(null);
|
||||
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
|
||||
const [accepted, setAccepted] = useState<boolean>(() => Boolean(localStorage.getItem(TOKEN_KEY)));
|
||||
const siteFields = useSiteFields(token, HOST_ORIGIN);
|
||||
const [messages, setMessages] = useState<WebMessage[]>([]);
|
||||
const [pending, setPending] = useState<string[]>([]);
|
||||
const [state, setState] = useState<"ai" | "operator" | "waiting">("ai");
|
||||
@@ -93,26 +95,7 @@ export function useChatSession() {
|
||||
setPending([]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!accepted || !token) return;
|
||||
let alive = true;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const data = await poll(token, lastId.current);
|
||||
if (alive) {
|
||||
ingestPoll(data, pollingReady.current);
|
||||
pollingReady.current = true;
|
||||
}
|
||||
} catch (error) {
|
||||
// Сессия истекла — иначе виджет молча висел бы с мёртвым токеном.
|
||||
if (alive && error instanceof SessionExpired) forgetSession();
|
||||
/* остальное — сеть или лимит: продолжаем опрашивать */
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
const timer = setInterval(tick, 2500);
|
||||
return () => { alive = false; clearInterval(timer); };
|
||||
}, [accepted, token]);
|
||||
useChatPolling(accepted ? token : null, lastId, pollingReady, ingestPoll, forgetSession);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToLatest();
|
||||
@@ -133,8 +116,9 @@ export function useChatSession() {
|
||||
}
|
||||
|
||||
async function accept() {
|
||||
if (starting) return;
|
||||
setStarting(true);
|
||||
const nextToken = await startSession(ENTRY, HOST_ORIGIN);
|
||||
const nextToken = await siteFields.start((fields) => startSession(ENTRY, HOST_ORIGIN, fields));
|
||||
setStarting(false);
|
||||
if (!nextToken) return;
|
||||
localStorage.setItem(TOKEN_KEY, nextToken);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { sendSiteFields } from "./api";
|
||||
import { SiteFieldsSender } from "./siteFields";
|
||||
|
||||
export function useSiteFields(token: string | null, hostOrigin: string) {
|
||||
const [sender] = useState(() => new SiteFieldsSender(sendSiteFields));
|
||||
useEffect(() => { sender.setToken(token); }, [sender, token]);
|
||||
useEffect(() => {
|
||||
function receive(event: MessageEvent) {
|
||||
if (event.source !== window.parent || event.origin !== hostOrigin) return;
|
||||
if (event.data?.type === "chatballs-set-fields") sender.merge(event.data.fields);
|
||||
}
|
||||
sender.resume();
|
||||
window.addEventListener("message", receive);
|
||||
// Подписка уже установлена: лоадер может безопасно отдать накопленные поля.
|
||||
window.parent.postMessage({ type: "chatballs-fields-ready" }, hostOrigin);
|
||||
return () => {
|
||||
window.removeEventListener("message", receive);
|
||||
sender.pause();
|
||||
};
|
||||
}, [sender, hostOrigin]);
|
||||
return sender;
|
||||
}
|
||||
@@ -83,7 +83,7 @@ test("genie geometry mirrors across the viewport and ends in the selected launch
|
||||
|
||||
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"]) {
|
||||
for (const module of ["site_fields", "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