mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-31 04:45:23 +08:00
220 lines
7.3 KiB
JavaScript
220 lines
7.3 KiB
JavaScript
'use strict';
|
|
// Renders a plugin page's inline script against a recording DOM.
|
|
//
|
|
// node page_dom_probe.js <page.html> <config.json>
|
|
//
|
|
// The harness exists so the test can check the *rendered* page instead of the
|
|
// page source: it runs the real inline script, records every markup string the
|
|
// page hands to `innerHTML` (verbatim, so the caller can parse it with any real
|
|
// HTML parser), records every element the page creates together with the
|
|
// properties, attributes and listeners attached to it, and records the Page-SDK
|
|
// calls the page makes. It is intentionally small: only what these pages use.
|
|
//
|
|
// config.json:
|
|
// { "responses": { "GET /entries": {...}, "/entries": {...} },
|
|
// "drive": "<javascript run inside the page after the ready handlers>" }
|
|
//
|
|
// The report is printed to stdout as JSON.
|
|
|
|
const fs = require('fs');
|
|
const vm = require('vm');
|
|
|
|
const pagePath = process.argv[2];
|
|
const config = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
|
|
const html = fs.readFileSync(pagePath, 'utf8');
|
|
|
|
const inlineScripts = [...html.matchAll(/<script(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/gi)].map(
|
|
(match) => match[1],
|
|
);
|
|
if (inlineScripts.length === 0) {
|
|
throw new Error(`no inline script found in ${pagePath}`);
|
|
}
|
|
|
|
const report = { markup_writes: [], elements: [], text_nodes: [], api_calls: [], alerts: [], errors: [] };
|
|
const created = [];
|
|
const byId = new Map();
|
|
|
|
// Browsers serialize `&`, `<` and `>` of a text node; quotes survive verbatim,
|
|
// which is exactly why text escaping is not attribute escaping.
|
|
const TEXT_ESCAPES = { '&': '&', '<': '<', '>': '>' };
|
|
|
|
function escapeText(text) {
|
|
return String(text).replace(/[&<>]/g, (ch) => TEXT_ESCAPES[ch]);
|
|
}
|
|
|
|
function makeElement(tag) {
|
|
const el = {
|
|
nodeType: 1,
|
|
tagName: String(tag).toUpperCase(),
|
|
className: '',
|
|
children: [],
|
|
attributes: {},
|
|
props: {},
|
|
listeners: {},
|
|
style: {},
|
|
classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
|
|
};
|
|
let text = '';
|
|
let markup = '';
|
|
|
|
Object.defineProperty(el, 'id', {
|
|
get() { return el.props.id || ''; },
|
|
set(value) { el.props.id = String(value); byId.set(String(value), el); },
|
|
});
|
|
for (const prop of ['value', 'checked', 'placeholder', 'min', 'max', 'type', 'rows', 'disabled']) {
|
|
Object.defineProperty(el, prop, {
|
|
get() { return el.props[prop]; },
|
|
set(value) { el.props[prop] = value; },
|
|
});
|
|
}
|
|
Object.defineProperty(el, 'textContent', {
|
|
get() { return text; },
|
|
set(value) { text = value === null || value === undefined ? '' : String(value); },
|
|
});
|
|
Object.defineProperty(el, 'innerHTML', {
|
|
get() { return markup === '' ? escapeText(text) : markup; },
|
|
set(value) {
|
|
markup = String(value);
|
|
report.markup_writes.push({ tag: el.tagName, id: el.props.id || null, markup });
|
|
el.children = [];
|
|
text = '';
|
|
},
|
|
});
|
|
|
|
el.appendChild = (child) => { el.children.push(child); return child; };
|
|
el.append = (...nodes) => { el.children.push(...nodes); };
|
|
el.replaceChildren = (...nodes) => { el.children = nodes.slice(); };
|
|
el.remove = () => {};
|
|
el.addEventListener = (type, fn) => { (el.listeners[type] || (el.listeners[type] = [])).push(fn); };
|
|
el.removeEventListener = (type, fn) => {
|
|
const list = el.listeners[type] || [];
|
|
const index = list.indexOf(fn);
|
|
if (index >= 0) list.splice(index, 1);
|
|
};
|
|
el.setAttribute = (name, value) => { el.attributes[String(name)] = String(value); };
|
|
el.getAttribute = (name) => (name in el.attributes ? el.attributes[name] : null);
|
|
el.removeAttribute = (name) => { delete el.attributes[name]; };
|
|
el.querySelector = () => null;
|
|
el.querySelectorAll = () => [];
|
|
el.focus = () => {};
|
|
return el;
|
|
}
|
|
|
|
function makeTextNode(text) {
|
|
return { nodeType: 3, tagName: '#text', textContent: String(text), children: [], attributes: {} };
|
|
}
|
|
|
|
const document = {
|
|
createElement(tag) { const el = makeElement(tag); created.push(el); return el; },
|
|
createTextNode(text) { const node = makeTextNode(text); created.push(node); return node; },
|
|
getElementById(id) {
|
|
if (!byId.has(id)) {
|
|
const el = makeElement('div');
|
|
el.id = id;
|
|
}
|
|
return byId.get(id);
|
|
},
|
|
querySelector: () => null,
|
|
querySelectorAll: () => [],
|
|
addEventListener: () => {},
|
|
body: makeElement('body'),
|
|
head: makeElement('head'),
|
|
documentElement: makeElement('html'),
|
|
};
|
|
|
|
const responses = config.responses || {};
|
|
const readyHandlers = [];
|
|
const languageHandlers = [];
|
|
|
|
const langbot = {
|
|
t: (key, fallback) => (fallback === undefined ? key : fallback),
|
|
api: async (endpoint, body, method) => {
|
|
method = method || 'GET';
|
|
report.api_calls.push({ method, endpoint, body: body === undefined ? null : body });
|
|
if (`${method} ${endpoint}` in responses) return responses[`${method} ${endpoint}`];
|
|
if (endpoint in responses) return responses[endpoint];
|
|
return {};
|
|
},
|
|
onReady: (fn) => { readyHandlers.push(fn); },
|
|
onLanguageChange: (fn) => { languageHandlers.push(fn); },
|
|
};
|
|
|
|
const probe = {
|
|
elements: () => created.filter((node) => node.nodeType === 1),
|
|
byId: (id) => byId.get(id) || null,
|
|
find: (predicate) => created.find((node) => node.nodeType === 1 && predicate(node)) || null,
|
|
click: async (label) => {
|
|
const button = created.find(
|
|
(node) => node.nodeType === 1 && node.tagName === 'BUTTON' && node.textContent === label,
|
|
);
|
|
if (!button) return false;
|
|
const results = (button.listeners.click || []).map((fn) => fn.call(button, { target: button }));
|
|
await Promise.all(results);
|
|
await probe.settle();
|
|
return true;
|
|
},
|
|
change: async (id) => {
|
|
const el = byId.get(id);
|
|
if (!el) return false;
|
|
await probe.fire(el, 'change');
|
|
return true;
|
|
},
|
|
fire: async (element, type) => {
|
|
const results = ((element && element.listeners[type]) || []).map((fn) =>
|
|
fn.call(element, { target: element }),
|
|
);
|
|
await Promise.all(results);
|
|
await probe.settle();
|
|
return results.length;
|
|
},
|
|
settle: () => new Promise((resolve) => setImmediate(resolve)),
|
|
};
|
|
|
|
const sandbox = {
|
|
document,
|
|
langbot,
|
|
console,
|
|
__probe: probe,
|
|
confirm: () => true,
|
|
alert: (message) => { report.alerts.push(String(message)); },
|
|
// Nothing in these pages depends on a timer firing; only toasts do.
|
|
setTimeout: () => 0,
|
|
clearTimeout: () => {},
|
|
};
|
|
vm.createContext(sandbox);
|
|
vm.runInContext('window = globalThis;', sandbox);
|
|
|
|
const source = inlineScripts.join('\n');
|
|
vm.runInContext(source, sandbox, { filename: pagePath });
|
|
|
|
(async () => {
|
|
try {
|
|
for (const handler of readyHandlers) await handler();
|
|
await probe.settle();
|
|
if (config.drive) {
|
|
await vm.runInContext(`(async () => {${config.drive}\n})()`, sandbox, { filename: 'drive' });
|
|
}
|
|
for (const handler of languageHandlers) await handler();
|
|
} catch (error) {
|
|
report.errors.push(String((error && error.stack) || error));
|
|
}
|
|
|
|
for (const node of created) {
|
|
if (node.nodeType === 3) {
|
|
report.text_nodes.push(node.textContent);
|
|
continue;
|
|
}
|
|
report.elements.push({
|
|
tag: node.tagName,
|
|
id: node.props.id || null,
|
|
className: node.className,
|
|
props: node.props,
|
|
attrs: node.attributes,
|
|
listeners: Object.keys(node.listeners),
|
|
text: node.textContent,
|
|
children: node.children.map((child) => `${child.tagName}${child.props && child.props.id ? '#' + child.props.id : ''}`),
|
|
});
|
|
}
|
|
process.stdout.write(`${JSON.stringify(report)}\n`);
|
|
})();
|