115 lines
8.2 KiB
JavaScript
115 lines
8.2 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
export const all = node => [node, ...node.children.flatMap(all)];
|
|
export class Events {
|
|
listeners = new Map();
|
|
addEventListener(type, fn) { if (!this.listeners.has(type)) this.listeners.set(type, new Set()); this.listeners.get(type).add(fn); }
|
|
removeEventListener(type, fn) { this.listeners.get(type)?.delete(fn); }
|
|
dispatch(type, event = {}) { for (const fn of [...(this.listeners.get(type) || [])]) fn(event); }
|
|
listenerCount() { return [...this.listeners.values()].reduce((sum, set) => sum + set.size, 0); }
|
|
}
|
|
export class Element extends Events {
|
|
constructor(tag) {
|
|
super();
|
|
this.tagName = tag; this.children = []; this.dataset = {}; this.attrs = {};
|
|
this.className = ''; this.value = ''; this._text = ''; this.disabled = false;
|
|
this.style = { setProperty(key, value) { this[key] = value; }, removeProperty(key) { delete this[key]; } };
|
|
this.classList = {
|
|
contains: name => this.className.split(/\s+/).includes(name),
|
|
add: (...names) => { this.className = [...new Set([...this.className.split(/\s+/).filter(Boolean), ...names])].join(' '); },
|
|
remove: (...names) => { this.className = this.className.split(/\s+/).filter(name => !names.includes(name)).join(' '); },
|
|
toggle: (name, force) => { const enabled = force ?? !this.classList.contains(name); this.classList[enabled ? 'add' : 'remove'](name); return enabled; },
|
|
};
|
|
}
|
|
set textContent(value) { this.replaceChildren(); this._text = String(value); }
|
|
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
|
|
set innerHTML(value) { this.textContent = value; }
|
|
get innerHTML() { return this.textContent; }
|
|
appendChild(child) { child.remove(); child.parentNode = this; this.children.push(child); return child; }
|
|
append(...children) { children.forEach(child => this.appendChild(child)); }
|
|
insertBefore(child, before) { if (!before) return this.appendChild(child); child.remove(); child.parentNode = this; this.children.splice(this.children.indexOf(before), 0, child); return child; }
|
|
prepend(child) { child.remove(); child.parentNode = this; this.children.unshift(child); }
|
|
replaceChildren(...children) { for (const child of this.children) child.parentNode = null; this.children = []; this._text = ''; this.append(...children); }
|
|
remove() { if (this.parentNode) this.parentNode.children = this.parentNode.children.filter(child => child !== this); this.parentNode = null; }
|
|
get parentElement() { return this.parentNode; }
|
|
get isConnected() { return this.tagName === 'body' || !!this.parentNode?.isConnected; }
|
|
get firstChild() { return this.children[0] || null; }
|
|
get options() { return this.children; }
|
|
cloneNode(deep = false) {
|
|
const copy = new Element(this.tagName);
|
|
copy.className = this.className; copy.value = this.value; copy._text = this._text;
|
|
copy.attrs = { ...this.attrs }; copy.dataset = { ...this.dataset };
|
|
if (deep) copy.append(...this.children.map(child => child.cloneNode(true)));
|
|
return copy;
|
|
}
|
|
setAttribute(name, value) { this.attrs[name] = value; }
|
|
removeAttribute(name) { delete this.attrs[name]; }
|
|
getAttribute(name) { return this.attrs[name] ?? null; }
|
|
matches(selector) { return selector.startsWith('#') ? this.id === selector.slice(1) : selector.startsWith('.') ? this.classList.contains(selector.slice(1)) : this.tagName === selector; }
|
|
querySelectorAll(selector) { return all(this).slice(1).filter(child => selector.split(',').some(part => child.matches(part.trim()))); }
|
|
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
|
|
closest(selector) { return this.matches(selector) ? this : this.parentNode?.closest(selector) || null; }
|
|
contains(child) { return all(this).includes(child); }
|
|
hasChildNodes() { return !!this.children.length; }
|
|
getBoundingClientRect() { return { width: parseFloat(this.style.width || this.style['--amb-translator-width']) || 580, height: 600, top: 0, left: 0, right: 580, bottom: 600 }; }
|
|
click() { if (!this.disabled) return this.onclick?.({ target: this, preventDefault() {}, stopPropagation() {} }); }
|
|
showModal() { this.open = true; }
|
|
close() { this.open = false; this.onclose?.(); }
|
|
focus() {}
|
|
scrollIntoView() {}
|
|
}
|
|
|
|
export function fixture({ storage = new Map(), storageOverride } = {}) {
|
|
const document = new Events();
|
|
document.body = new Element('body');
|
|
document.createElement = tag => new Element(tag);
|
|
document.createDocumentFragment = () => new Element('fragment');
|
|
document.querySelector = selector => document.body.querySelector(selector);
|
|
document.querySelectorAll = selector => document.body.querySelectorAll(selector);
|
|
const window = new Events();
|
|
Object.assign(window, { innerWidth: 1440, innerHeight: 900, anomalous_browser_lang: 'en' });
|
|
const timers = new Map();
|
|
let timerId = 0;
|
|
const errors = [], requests = [], clipboard = [];
|
|
const node = { id: 1, type: 'CLIPTextEncode', widgets: [{ name: 'text', type: 'customtext', value: 'original' }], widgets_values: ['original'] };
|
|
const app = { graph: { getNodeById: id => id === node.id ? node : null }, canvas: { selected_nodes: { 1: node } } };
|
|
const state = {
|
|
app, node, window, document, errors, requests, clipboard,
|
|
async fetch(url) { return { ok: true, json: async () => String(url).includes('/materials?') ? { status: 'success', materials: [], pages: 1 } : { status: 'success', translated: 'translated' } }; },
|
|
};
|
|
const context = vm.createContext({ console, URL, URLSearchParams, Blob, AbortController, DOMException, structuredClone,
|
|
document, window, app, errors, anomalous_browser_lang: 'en',
|
|
setTimeout: callback => { timers.set(++timerId, callback); return timerId; }, clearTimeout: id => timers.delete(id),
|
|
requestAnimationFrame: callback => callback(), queueMicrotask,
|
|
localStorage: storageOverride || { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, String(value)), removeItem: key => storage.delete(key) },
|
|
navigator: { clipboard: { async writeText(text) { clipboard.push(text); } } }, confirm: () => true,
|
|
fetch: async (url, options = {}) => { requests.push([url, options]); return state.fetch(url, options); },
|
|
});
|
|
const modules = new Map();
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../web/modules');
|
|
const load = filename => {
|
|
if (modules.has(filename)) return modules.get(filename);
|
|
let source;
|
|
if (filename.endsWith('/scripts/app.js')) source = 'export const app = globalThis.app;';
|
|
else if (filename.endsWith('/ui_dialog.js')) source = 'export async function anomalousAlert(message) { errors.push(message); } export async function anomalousConfirm() { return true; } export async function anomalousPrompt() { return null; }';
|
|
else if (filename.endsWith('/material_feedback.js')) source = 'export function showMaterialSaved() {}';
|
|
else if (filename.endsWith('/ui_gallery_detail.js')) source = 'export function showImageWorkbench() {}';
|
|
else source = fs.readFileSync(filename, 'utf8');
|
|
const mod = new vm.SourceTextModule(source, { context, identifier: filename, initializeImportMeta: meta => { meta.url = pathToFileURL(filename).href; } });
|
|
modules.set(filename, mod); return mod;
|
|
};
|
|
state.module = async name => {
|
|
const mod = load(path.resolve(root, name).replaceAll('\\', '/'));
|
|
if (mod.status === 'unlinked') await mod.link((specifier, parent) => load(path.resolve(path.dirname(parent.identifier), specifier).replaceAll('\\', '/')));
|
|
if (mod.status === 'linked') await mod.evaluate();
|
|
return mod.namespace;
|
|
};
|
|
state.button = (rootNode, label) => all(rootNode).find(el => el.tagName === 'button' && el.textContent.includes(label));
|
|
state.flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
|
|
state.runTimers = () => { const pending = [...timers.values()]; timers.clear(); pending.forEach(callback => callback()); };
|
|
return state;
|
|
}
|