Files
Webklar-Kundenbereich/test/support/fakeDom.js
T
ClaudeandClaude Sonnet 5.5 87d267e51a Terminplaner TP2: Ansicht nach Zustand und Rueckkehr von Stripe
Die Ansicht #termine folgt dem Zustand aus GET /api/termine/einstellungen:
off mit laufendem Abo zeigt den Fragebogen samt Bestellseite, ohne Abo den
Hinweis des Servers (termine.hinweis, R111) und bei kein_abo den Link zu
Vertraege aus einer gemeinsamen Quelle (vertraege-link.js); ordered und setup
zeigen die Statuskarte und die Reiter Leistungen, Team & Plaetze und
Einstellungen nur zum Lesen (ohne Reiter Termine); active und disabled
bleiben wie bisher. Rueckkehr von Stripe: #termine?bestellt=1&projekt=<id>
und #termine?projekt=<id> waehlen das Projekt, "Danke!" nur mit bestellt=1,
die Adresse wird einmal gelesen und bereinigt.

R110: GET /einstellungen liefert bestellung und offen auch in ordered (ohne
Abo-Punkt, abo bleibt null); scheitert "Zahlung fortsetzen", zeichnet die
Huelle Liste und Daten neu (bei 409 zustimmung_fehlt mit dem
Bestellformular, sonst nach dem neuen Zustand, die Meldung bleibt am Knopf,
solange die Bestellung offen ist).

Dazu: "null" als Text im lesbaren Reiter Leistungen entfernt, Export und
Status der Einstellungen erst ab der Freischaltung.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 23:10:54 +00:00

526 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Minimal-DOM und Fake-fetch fuer Tests des Portal-Clients (public/termine*.js, public/funktion-bestellen.js) ohne Browser. Nicht Teil von npm test.
//
// const fx = installFakeDom({ antworten: [{ status: 400, body: { fehler: {...} } }] })
// try { ...Client-Code bauen, fx.klick(knopf), fx.fetchLog pruefen... } finally { fx.restore() }
//
// installFakeDom stellt `document`, `window` (nur location mit href, pathname, search, hash und history.replaceState), `CSS.escape` und `fetch` auf globalThis, restore() stellt den
// alten Zustand wieder her. `h` aus public/termine-dom.js liest `document` bei jedem Aufruf: der Client braucht keine Einhaengepunkte.
// Wurzeln, die `isConnected` brauchen (neuerSpeicher.nochHier), haengt der Test selbst an `document.body`.
//
// Elemente: createElement, setAttribute/getAttribute/hasAttribute/removeAttribute, append/prepend/appendChild/insertBefore/replaceChildren/remove,
// children/childNodes/parentNode/parentElement/firstChild/lastChild/firstElementChild/lastElementChild, className/classList/id/dataset/hidden/
// disabled/tabIndex/type/name, value/checked/selected (Eigenschaft ueberdeckt das Attribut wie im Browser; select.value ist die gewaehlte
// Option, ohne Wahl die erste), textContent, isConnected, closest/matches/querySelector/querySelectorAll, focus (document.activeElement)/blur/
// select/scrollIntoView, addEventListener/dispatchEvent (Ereignisse steigen zu den Eltern auf, preventDefault/stopPropagation).
// Selektoren: Tag, *, #id, .klasse, [attr], [attr="wert"], :not(…), :disabled, :enabled, :checked, Komma, Leerzeichen und >.
//
// Der Fake-fetch schreibt jeden Aufruf als { method, url, body } (body = geparstes JSON oder undefined) in fetchLog und antwortet mit dem naechsten
// Eintrag aus `antworten` ({ status, body } oder eine Funktion (aufruf) => { status, body }); ohne Eintrag 200 mit {}.
// klick, setze, sende und ruhe liefern ein Promise: die Wirkung der Ereignisse (auch von async-Handlern) ist erst danach da, also `await fx.klick(…)`.
// klick wirkt wie ein Klick im Browser (Haken und Radio schalten, „submit“-Knopf sendet das Formular, gesperrte Elemente tun nichts); ein Klick auf ein
// <label> schaltet seinen Haken NICHT: der Test klickt den Haken selbst. sende(el, typ, init) schickt ein beliebiges Ereignis (z. B. 'keydown', { key }).
import { setImmediate as naechsterTakt } from 'node:timers/promises'
/* --------------------------------------------------------------- CSS.escape --- */
/** CSS.escape nach der Spezifikation (die Client-Dateien bauen damit Attribut-Selektoren aus Feldnamen wie 'leistungen.2.dauer'). */
export function cssEscape(wert) {
const s = String(wert)
let aus = ''
for (let i = 0; i < s.length; i += 1) {
const c = s[i]
const code = s.charCodeAt(i)
if (code === 0) aus += '\uFFFD'
else if ((code >= 1 && code <= 31) || code === 127 || (i === 0 && c >= '0' && c <= '9') || (i === 1 && c >= '0' && c <= '9' && s[0] === '-')) aus += `\\${code.toString(16)} `
else if (i === 0 && c === '-' && s.length === 1) aus += `\\${c}`
else if (code >= 128 || c === '-' || c === '_' || /[0-9A-Za-z]/.test(c)) aus += c
else aus += `\\${c}`
}
return aus
}
const entescape = (text) => text.replace(/\\([0-9a-fA-F]{1,6})\s?|\\(.)/g, (_, hex, zeichen) => (hex ? String.fromCodePoint(parseInt(hex, 16)) : zeichen))
/* ---------------------------------------------------------------- Selektoren --- */
const STEUERUNGEN = new Set(['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'FIELDSET', 'OPTION'])
/** :disabled – eigenes Attribut oder ein gesperrtes fieldset darueber (wie im Browser). */
function istGesperrt(el) {
if (!STEUERUNGEN.has(el.tagName)) return false
if (el.hasAttribute('disabled')) return true
for (let p = el.parentNode; p && p.nodeType === 1; p = p.parentNode) if (p.tagName === 'FIELDSET' && p.hasAttribute('disabled')) return true
return false
}
const TEIL_QUELLE = /(\*|[A-Za-z][\w-]*)|#((?:\\.|[\w-])+)|\.((?:\\.|[\w-])+)|\[\s*([\w:-]+)\s*(?:(=)\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^\]\s]*)))?\s*\]|:not\(([^)]*)\)|:([a-z-]+)/
/** Ein einfacher Selektor (ohne Leerzeichen/Komma) als Pruefung el => boolean. */
function einfach(text) {
const tests = []
const teil = new RegExp(TEIL_QUELLE.source, 'y') // je Aufruf eigener Zustand: :not(…) ruft einfach() verschachtelt auf
while (teil.lastIndex < text.length) {
const m = teil.exec(text)
if (!m) throw new Error(`fakeDom: Selektor nicht unterstuetzt: ${text}`)
const [, tag, id, klasse, attr, op, doppelt, einzeln, blank, nicht, pseudo] = m
if (tag) tests.push(tag === '*' ? () => true : (el) => el.tagName === tag.toUpperCase())
else if (id) tests.push((el) => el.id === entescape(id))
else if (klasse) tests.push((el) => el.classList.contains(entescape(klasse)))
else if (attr) {
const soll = op ? entescape(doppelt ?? einzeln ?? blank ?? '') : null
tests.push((el) => (op ? el.getAttribute(attr) === soll : el.hasAttribute(attr)))
} else if (nicht !== undefined) {
const liste = parse(nicht)
tests.push((el) => !liste.some((komplex) => passt(el, komplex)))
} else if (pseudo === 'disabled') tests.push((el) => istGesperrt(el))
else if (pseudo === 'enabled') tests.push((el) => STEUERUNGEN.has(el.tagName) && !istGesperrt(el))
else if (pseudo === 'checked') tests.push((el) => (el.tagName === 'OPTION' ? el.selected : el.tagName === 'INPUT' && el.checked))
else throw new Error(`fakeDom: Pseudo-Klasse nicht unterstuetzt: :${pseudo}`)
}
return (el) => tests.every((test) => test(el))
}
/** Selektorliste ⇒ Array von Ketten [{ komb, test }] (komb = Verbindung zum Vorgaenger: ' ' Nachfahre, '>' Kind). */
function parse(quelle) {
const liste = []
let kette = []
let stueck = ''
let komb = ' '
let tiefe = 0
let quote = null
const schliesse = () => {
if (!stueck) return
kette.push({ komb, test: einfach(stueck) })
stueck = ''
komb = ' '
}
for (let i = 0; i < quelle.length; i += 1) {
const zeichen = quelle[i]
if (zeichen === '\\') { stueck += zeichen + (quelle[i + 1] ?? ''); i += 1; continue }
if (quote) { stueck += zeichen; if (zeichen === quote) quote = null; continue }
if (zeichen === '"' || zeichen === "'") { quote = zeichen; stueck += zeichen; continue }
if (zeichen === '[' || zeichen === '(') tiefe += 1
if (zeichen === ']' || zeichen === ')') tiefe -= 1
if (tiefe === 0 && zeichen === ',') { schliesse(); if (kette.length) liste.push(kette); kette = []; continue }
if (tiefe === 0 && /\s/.test(zeichen)) { schliesse(); continue }
if (tiefe === 0 && zeichen === '>') { schliesse(); komb = '>'; continue }
stueck += zeichen
}
schliesse()
if (kette.length) liste.push(kette)
if (!liste.length) throw new Error(`fakeDom: leerer Selektor: "${quelle}"`)
return liste
}
function passt(el, kette, i = kette.length - 1) {
if (!kette[i].test(el)) return false
if (i === 0) return true
if (kette[i].komb === '>') return el.parentNode?.nodeType === 1 && passt(el.parentNode, kette, i - 1)
for (let p = el.parentNode; p && p.nodeType === 1; p = p.parentNode) if (passt(p, kette, i - 1)) return true
return false
}
const trifft = (el, selektor) => parse(selektor).some((kette) => passt(el, kette))
/* -------------------------------------------------------------------- Knoten --- */
class FakeText {
constructor(text) {
this.nodeType = 3
this.data = String(text)
this.parentNode = null
}
get textContent() { return this.data }
set textContent(wert) { this.data = String(wert) }
}
const kebab = (name) => `data-${name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`
const alsKnoten = (doc, wert) => (typeof wert === 'object' && wert !== null ? wert : new FakeText(wert))
class FakeElement {
constructor(doc, tag) {
this.ownerDocument = doc
this.nodeType = 1
this.tagName = String(tag).toUpperCase()
this.parentNode = null
this.childNodes = []
this._attrs = new Map()
this._listeners = new Map()
this._value = undefined
this._checked = undefined
this._selected = undefined
const el = this
this.dataset = new Proxy({}, {
get: (_, name) => (typeof name === 'string' ? el._attrs.get(kebab(name)) : undefined),
set: (_, name, wert) => { el.setAttribute(kebab(name), wert); return true },
has: (_, name) => el._attrs.has(kebab(name)),
deleteProperty: (_, name) => { el._attrs.delete(kebab(name)); return true },
})
const liste = () => (el._attrs.get('class') ?? '').split(/\s+/).filter(Boolean)
this.classList = {
contains: (name) => liste().includes(name),
add: (...namen) => el.setAttribute('class', [...new Set([...liste(), ...namen])].join(' ')),
remove: (...namen) => el.setAttribute('class', liste().filter((n) => !namen.includes(n)).join(' ')),
toggle: (name, an) => {
const soll = an ?? !liste().includes(name)
if (soll) this.classList.add(name)
else this.classList.remove(name)
return soll
},
}
}
/* Attribute */
setAttribute(name, wert) { this._attrs.set(name, String(wert)) }
getAttribute(name) { return this._attrs.has(name) ? this._attrs.get(name) : null }
hasAttribute(name) { return this._attrs.has(name) }
removeAttribute(name) { this._attrs.delete(name) }
get id() { return this._attrs.get('id') ?? '' }
set id(wert) { this.setAttribute('id', wert) }
get className() { return this._attrs.get('class') ?? '' }
set className(wert) { this.setAttribute('class', wert) }
get hidden() { return this._attrs.has('hidden') }
set hidden(an) { if (an) this.setAttribute('hidden', ''); else this.removeAttribute('hidden') }
get disabled() { return this._attrs.has('disabled') }
set disabled(an) { if (an) this.setAttribute('disabled', ''); else this.removeAttribute('disabled') }
get readOnly() { return this._attrs.has('readonly') }
set readOnly(an) { if (an) this.setAttribute('readonly', ''); else this.removeAttribute('readonly') }
get tabIndex() { return this._attrs.has('tabindex') ? Number(this._attrs.get('tabindex')) : -1 }
set tabIndex(wert) { this.setAttribute('tabindex', wert) }
get type() { return this._attrs.get('type') ?? (this.tagName === 'BUTTON' ? 'submit' : 'text') }
set type(wert) { this.setAttribute('type', wert) }
get name() { return this._attrs.get('name') ?? '' }
set name(wert) { this.setAttribute('name', wert) }
/* value, checked, selected: die Eigenschaft ueberdeckt das Attribut, sobald sie gesetzt ist */
get value() {
if (this.tagName === 'SELECT') {
const optionen = this.querySelectorAll('option')
return (optionen.find((o) => o.selected) ?? optionen.find((o) => !o.disabled))?.value ?? ''
}
if (this.tagName === 'OPTION') return this._attrs.get('value') ?? this.textContent
if (this.tagName === 'TEXTAREA') return this._value ?? this.textContent
return this._value ?? this._attrs.get('value') ?? (this.type === 'checkbox' || this.type === 'radio' ? 'on' : '')
}
set value(wert) {
if (this.tagName === 'SELECT') {
for (const o of this.querySelectorAll('option')) o._selected = o.value === String(wert)
} else if (this.tagName === 'OPTION') this.setAttribute('value', wert)
else this._value = String(wert)
}
get checked() { return this._checked ?? this._attrs.has('checked') }
set checked(an) {
this._checked = Boolean(an)
if (an && this.type === 'radio' && this.name) {
let wurzel = this
while (wurzel.parentNode) wurzel = wurzel.parentNode
for (const r of wurzel.querySelectorAll('input')) if (r !== this && r.type === 'radio' && r.name === this.name) r._checked = false
}
}
get selected() { return this._selected ?? this._attrs.has('selected') }
set selected(an) {
this._selected = Boolean(an)
if (an && this.parentNode?.tagName === 'SELECT') for (const o of this.parentNode.querySelectorAll('option')) if (o !== this) o._selected = false
}
/* Baum */
get children() { return this.childNodes.filter((n) => n.nodeType === 1) }
get parentElement() { return this.parentNode?.nodeType === 1 ? this.parentNode : null }
get firstChild() { return this.childNodes[0] ?? null }
get lastChild() { return this.childNodes.at(-1) ?? null }
get firstElementChild() { return this.children[0] ?? null }
get lastElementChild() { return this.children.at(-1) ?? null }
get isConnected() {
let wurzel = this
while (wurzel.parentNode) wurzel = wurzel.parentNode
return wurzel === this.ownerDocument.documentElement
}
get textContent() { return this.childNodes.map((n) => n.textContent).join('') }
set textContent(wert) { this.replaceChildren(...(wert === '' || wert === null || wert === undefined ? [] : [String(wert)])) }
_loese(knoten) {
if (knoten.parentNode) knoten.parentNode.removeChild(knoten)
return knoten
}
append(...knoten) {
for (const wert of knoten) {
const k = this._loese(alsKnoten(this.ownerDocument, wert))
k.parentNode = this
this.childNodes.push(k)
}
}
prepend(...knoten) {
const neu = knoten.map((wert) => this._loese(alsKnoten(this.ownerDocument, wert)))
for (const k of neu) k.parentNode = this
this.childNodes.unshift(...neu)
}
appendChild(knoten) { this.append(knoten); return knoten }
insertBefore(knoten, vor) {
if (!vor) return this.appendChild(knoten)
this._loese(knoten)
knoten.parentNode = this
this.childNodes.splice(this.childNodes.indexOf(vor), 0, knoten)
return knoten
}
removeChild(knoten) {
const i = this.childNodes.indexOf(knoten)
if (i < 0) throw new Error('fakeDom: removeChild: kein Kind dieses Elements')
this.childNodes.splice(i, 1)
knoten.parentNode = null
return knoten
}
replaceChildren(...knoten) {
for (const k of this.childNodes) k.parentNode = null
this.childNodes = []
this.append(...knoten)
}
remove() { this.parentNode?.removeChild(this) }
contains(knoten) {
for (let k = knoten; k; k = k.parentNode) if (k === this) return true
return false
}
/* Suche */
matches(selektor) { return trifft(this, selektor) }
closest(selektor) {
for (let el = this; el && el.nodeType === 1; el = el.parentNode) if (trifft(el, selektor)) return el
return null
}
querySelectorAll(selektor) {
const liste = parse(selektor)
const treffer = []
const durchlauf = (el) => {
for (const kind of el.children) {
if (liste.some((kette) => passt(kind, kette))) treffer.push(kind)
durchlauf(kind)
}
}
durchlauf(this)
return treffer
}
querySelector(selektor) { return this.querySelectorAll(selektor)[0] ?? null }
/* Fokus */
focus() {
if (!this.isConnected || istGesperrt(this)) return
const doc = this.ownerDocument
const davor = doc._aktiv
doc._aktiv = this
if (davor !== this) feuere(this, neuesEreignis('focus', { bubbles: false }))
}
blur() { if (this.ownerDocument._aktiv === this) this.ownerDocument._aktiv = null }
select() {}
scrollIntoView() {}
click() { return klickEl(this) }
/* Ereignisse */
addEventListener(typ, fn) {
if (!this._listeners.has(typ)) this._listeners.set(typ, [])
this._listeners.get(typ).push(fn)
}
removeEventListener(typ, fn) {
const liste = this._listeners.get(typ)
if (liste?.includes(fn)) liste.splice(liste.indexOf(fn), 1)
}
dispatchEvent(ev) {
feuere(this, ev)
return !ev.defaultPrevented
}
}
/* ----------------------------------------------------------------- Ereignisse --- */
function neuesEreignis(typ, init = {}) {
return {
type: typ,
target: null,
currentTarget: null,
bubbles: true,
defaultPrevented: false,
stopped: false,
ergebnisse: [],
preventDefault() { this.defaultPrevented = true },
stopPropagation() { this.stopped = true },
...init,
}
}
/** Ereignis an el senden und zu den Eltern aufsteigen lassen; Rueckgaben der Handler (async-Handler: Promises) landen in ev.ergebnisse. */
function feuere(el, ev) {
ev.target = el
for (let knoten = el; knoten && knoten.nodeType === 1; knoten = knoten.parentNode) {
ev.currentTarget = knoten
for (const fn of [...(knoten._listeners.get(ev.type) ?? [])]) ev.ergebnisse.push(fn.call(knoten, ev))
if (ev.stopped || !ev.bubbles) break
}
}
/** Alle Handler eines Ereignisses abwarten und danach einen Takt, in dem Microtask-Ketten (fetch, await) durchlaufen. */
async function abwarten(ergebnisse) {
await Promise.all(ergebnisse)
await naechsterTakt()
}
/** Klick wie im Browser: Haken setzen/umschalten, click, input/change, bei „submit“-Knopf in einem Formular submit. */
function klickEl(el) {
if (istGesperrt(el)) return abwarten([])
const ergebnisse = []
const art = el.tagName === 'INPUT' ? el.type : null
if (art === 'checkbox') el.checked = !el.checked
else if (art === 'radio') el.checked = true
const klick = neuesEreignis('click')
feuere(el, klick)
ergebnisse.push(...klick.ergebnisse)
if (!klick.defaultPrevented) {
if (art === 'checkbox' || art === 'radio') {
for (const typ of ['input', 'change']) {
const ev = neuesEreignis(typ)
feuere(el, ev)
ergebnisse.push(...ev.ergebnisse)
}
} else if ((el.tagName === 'BUTTON' && el.type === 'submit') || (art === 'submit')) {
const form = el.closest('form')
if (form) {
const ev = neuesEreignis('submit', { submitter: el })
feuere(form, ev)
ergebnisse.push(...ev.ergebnisse)
}
}
}
return abwarten(ergebnisse)
}
/** Wert eingeben wie ein Nutzer: Text/Auswahl als String, Haken/Radio als Boolean; danach input und change. */
function setzeEl(el, wert) {
if (istGesperrt(el)) return abwarten([])
if (el.tagName === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')) el.checked = Boolean(wert)
else el.value = wert
const ergebnisse = []
for (const typ of ['input', 'change']) {
const ev = neuesEreignis(typ)
feuere(el, ev)
ergebnisse.push(...ev.ergebnisse)
}
return abwarten(ergebnisse)
}
/* -------------------------------------------------------------------- Einbau --- */
function neuesDokument() {
const doc = { nodeType: 9, _aktiv: null }
doc.createElement = (tag) => new FakeElement(doc, tag)
doc.createTextNode = (text) => new FakeText(text)
doc.documentElement = doc.createElement('html')
doc.head = doc.createElement('head')
doc.body = doc.createElement('body')
doc.documentElement.append(doc.head, doc.body)
Object.defineProperty(doc, 'activeElement', { get: () => (doc._aktiv?.isConnected ? doc._aktiv : doc.body) })
doc.querySelector = (selektor) => (trifft(doc.documentElement, selektor) ? doc.documentElement : doc.documentElement.querySelector(selektor))
doc.querySelectorAll = (selektor) => doc.documentElement.querySelectorAll(selektor)
doc.getElementById = (id) => doc.documentElement.querySelector(`[id="${id}"]`)
return doc
}
const GLOBALE = ['document', 'window', 'CSS', 'fetch']
/**
* Fake-DOM und Fake-fetch auf globalThis stellen.
* => { document, window, fetchLog, antworten, finde, alle, text, klick, setze, sende, ruhe, restore }
* finde(baum, vergleich) / alle(baum, vergleich): erstes / alle Elemente im Baum (baum selbst eingeschlossen, Dokumentreihenfolge), fuer die
* vergleich(el) wahr ist (Funktion) oder die zum Selektor passen (String).
* text(baum): Text des Baums (textContent). klick(el), setze(el, wert), ruhe(): siehe Kopf der Datei.
*/
export function installFakeDom({ antworten = [] } = {}) {
const vorher = Object.fromEntries(GLOBALE.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)]))
const document = neuesDokument()
const location = { href: 'http://localhost/', pathname: '/dashboard.html', search: '', hash: '' }
const window = {
document,
location,
history: { replaceState: (_zustand, _titel, url) => { location.hash = String(url ?? '').includes('#') ? `#${String(url).split('#')[1]}` : '' } },
}
const fetchLog = []
const warteschlange = [...antworten]
const fakeFetch = async (url, optionen = {}) => {
const aufruf = { method: optionen.method || 'GET', url: String(url), body: optionen.body ? JSON.parse(optionen.body) : undefined }
fetchLog.push(aufruf)
const eintrag = warteschlange.shift()
const { status = 200, body = {} } = (typeof eintrag === 'function' ? eintrag(aufruf) : eintrag) || {}
return { ok: status >= 200 && status < 300, status, json: async () => structuredClone(body) }
}
const neu = { document, window, CSS: { escape: cssEscape }, fetch: fakeFetch }
for (const [name, wert] of Object.entries(neu)) Object.defineProperty(globalThis, name, { value: wert, writable: true, configurable: true, enumerable: true })
const knoten = (baum) => [baum, ...(baum?.querySelectorAll?.('*') ?? [])].filter((n) => n?.nodeType === 1)
const pruefung = (vergleich) => (typeof vergleich === 'string' ? (el) => trifft(el, vergleich) : vergleich)
return {
document,
window,
fetchLog,
antworten: warteschlange,
finde: (baum, vergleich) => knoten(baum).find(pruefung(vergleich)) ?? null,
alle: (baum, vergleich) => knoten(baum).filter(pruefung(vergleich)),
text: (baum) => baum?.textContent ?? '',
klick: klickEl,
setze: setzeEl,
sende: (el, typ, init) => {
const ev = neuesEreignis(typ, init)
feuere(el, ev)
return abwarten(ev.ergebnisse)
},
ruhe: () => abwarten([]),
restore() {
for (const name of GLOBALE) {
if (vorher[name]) Object.defineProperty(globalThis, name, vorher[name])
else delete globalThis[name]
}
},
}
}