auto-webmcp 0.3.13 → 0.3.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/auto-webmcp.cjs.js +186 -24
- package/dist/auto-webmcp.cjs.js.map +2 -2
- package/dist/auto-webmcp.esm.js +186 -24
- package/dist/auto-webmcp.esm.js.map +2 -2
- package/dist/auto-webmcp.iife.js +1 -1
- package/dist/auto-webmcp.iife.js.map +3 -3
- package/dist/discovery.d.ts.map +1 -1
- package/dist/interceptor.d.ts +10 -0
- package/dist/interceptor.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/analyzer.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../src/analyzer.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,UAAU,EAAmJ,MAAM,aAAa,CAAC;AAC1L,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,MAAM,WAAW,eAAe;IAC9B,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,UAAU,CAAC;IACxB,WAAW,CAAC,EAAE,eAAe,CAAC;IAC9B,6FAA6F;IAC7F,aAAa,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACtC;AAKD,iDAAiD;AACjD,wBAAgB,cAAc,IAAI,IAAI,CAErC;AAED,gDAAgD;AAChD,wBAAgB,WAAW,CAAC,IAAI,EAAE,eAAe,EAAE,QAAQ,CAAC,EAAE,YAAY,GAAG,YAAY,CAOxF;
|
|
1
|
+
{"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../src/analyzer.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,UAAU,EAAmJ,MAAM,aAAa,CAAC;AAC1L,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,MAAM,WAAW,eAAe;IAC9B,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,UAAU,CAAC;IACxB,WAAW,CAAC,EAAE,eAAe,CAAC;IAC9B,6FAA6F;IAC7F,aAAa,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACtC;AAKD,iDAAiD;AACjD,wBAAgB,cAAc,IAAI,IAAI,CAErC;AAED,gDAAgD;AAChD,wBAAgB,WAAW,CAAC,IAAI,EAAE,eAAe,EAAE,QAAQ,CAAC,EAAE,YAAY,GAAG,YAAY,CAOxF;AA4uBD;;;GAGG;AACH,wBAAgB,uBAAuB,CACrC,SAAS,EAAE,OAAO,EAClB,MAAM,EAAE,KAAK,CAAC,gBAAgB,GAAG,mBAAmB,GAAG,iBAAiB,GAAG,WAAW,CAAC,EACvF,SAAS,EAAE,iBAAiB,GAAG,gBAAgB,GAAG,IAAI,GACrD,YAAY,CAMd"}
|
package/dist/auto-webmcp.cjs.js
CHANGED
|
@@ -509,14 +509,15 @@ function collectShadowControls(root, visited = /* @__PURE__ */ new Set()) {
|
|
|
509
509
|
const results = [];
|
|
510
510
|
for (const el of Array.from(root.querySelectorAll("*"))) {
|
|
511
511
|
if (el.shadowRoot) {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
)
|
|
517
|
-
),
|
|
518
|
-
...collectShadowControls(el.shadowRoot, visited)
|
|
512
|
+
const found = Array.from(
|
|
513
|
+
el.shadowRoot.querySelectorAll(
|
|
514
|
+
"input, textarea, select"
|
|
515
|
+
)
|
|
519
516
|
);
|
|
517
|
+
if (found.length > 0) {
|
|
518
|
+
console.log(`[auto-webmcp] shadow: found ${found.length} control(s) in ${el.tagName.toLowerCase()} shadow root:`, found.map((f) => `${f.tagName.toLowerCase()}[type=${f.type ?? "?"}][name="${f.name}"][id="${f.id}"]`));
|
|
519
|
+
}
|
|
520
|
+
results.push(...found, ...collectShadowControls(el.shadowRoot, visited));
|
|
520
521
|
}
|
|
521
522
|
}
|
|
522
523
|
return results;
|
|
@@ -600,9 +601,23 @@ function buildSchema(form) {
|
|
|
600
601
|
if (!name) {
|
|
601
602
|
fieldElements.set(fieldKey, control);
|
|
602
603
|
}
|
|
603
|
-
|
|
604
|
-
|
|
604
|
+
let isRequired = control.required;
|
|
605
|
+
if (!isRequired) {
|
|
606
|
+
let hostNode = control;
|
|
607
|
+
while (true) {
|
|
608
|
+
const root = hostNode.getRootNode();
|
|
609
|
+
if (!(root instanceof ShadowRoot))
|
|
610
|
+
break;
|
|
611
|
+
const host = root.host;
|
|
612
|
+
if (host.hasAttribute("required") || host.getAttribute("aria-required") === "true") {
|
|
613
|
+
isRequired = true;
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
616
|
+
hostNode = host;
|
|
617
|
+
}
|
|
605
618
|
}
|
|
619
|
+
if (isRequired)
|
|
620
|
+
required.push(fieldKey);
|
|
606
621
|
}
|
|
607
622
|
const ariaControls = collectAriaControls(form);
|
|
608
623
|
const processedAriaRadioGroups = /* @__PURE__ */ new Set();
|
|
@@ -645,11 +660,40 @@ function resolveNativeControlFallbackKey(control) {
|
|
|
645
660
|
if ((control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement) && control.placeholder?.trim()) {
|
|
646
661
|
return sanitizeName(control.placeholder.trim());
|
|
647
662
|
}
|
|
663
|
+
const hostKey = resolveShadowHostKey(control);
|
|
664
|
+
if (hostKey)
|
|
665
|
+
return hostKey;
|
|
648
666
|
if (control instanceof HTMLInputElement && control.type !== "text") {
|
|
649
667
|
return control.type;
|
|
650
668
|
}
|
|
651
669
|
return null;
|
|
652
670
|
}
|
|
671
|
+
function resolveShadowHostKey(el) {
|
|
672
|
+
let node = el;
|
|
673
|
+
while (true) {
|
|
674
|
+
const root = node.getRootNode();
|
|
675
|
+
if (!(root instanceof ShadowRoot))
|
|
676
|
+
break;
|
|
677
|
+
const host = root.host;
|
|
678
|
+
const fieldName = host.getAttribute("field-name");
|
|
679
|
+
if (fieldName) {
|
|
680
|
+
console.log("[auto-webmcp] shadow host key: field-name=", fieldName);
|
|
681
|
+
return sanitizeName(fieldName);
|
|
682
|
+
}
|
|
683
|
+
const hostLabel = host.getAttribute("label") || host.getAttribute("aria-label");
|
|
684
|
+
if (hostLabel) {
|
|
685
|
+
console.log("[auto-webmcp] shadow host key: label=", hostLabel);
|
|
686
|
+
return sanitizeName(hostLabel);
|
|
687
|
+
}
|
|
688
|
+
const hostName = host.getAttribute("name");
|
|
689
|
+
if (hostName) {
|
|
690
|
+
console.log("[auto-webmcp] shadow host key: name=", hostName);
|
|
691
|
+
return sanitizeName(hostName);
|
|
692
|
+
}
|
|
693
|
+
node = host;
|
|
694
|
+
}
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
653
697
|
function resolveAriaElementKey(el) {
|
|
654
698
|
if (el.dataset["webmcpName"])
|
|
655
699
|
return sanitizeName(el.dataset["webmcpName"]);
|
|
@@ -811,12 +855,40 @@ function getAssociatedLabelText(control) {
|
|
|
811
855
|
return text;
|
|
812
856
|
}
|
|
813
857
|
}
|
|
858
|
+
const ownRoot = control.getRootNode();
|
|
859
|
+
if (ownRoot instanceof ShadowRoot) {
|
|
860
|
+
if (control.id) {
|
|
861
|
+
const shadowLabel = ownRoot.querySelector(`label[for="${CSS.escape(control.id)}"]`);
|
|
862
|
+
if (shadowLabel) {
|
|
863
|
+
const text = labelTextWithoutNested(shadowLabel);
|
|
864
|
+
if (text)
|
|
865
|
+
return text;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
const anyLabel = ownRoot.querySelector("label");
|
|
869
|
+
if (anyLabel) {
|
|
870
|
+
const text = labelTextWithoutNested(anyLabel);
|
|
871
|
+
if (text)
|
|
872
|
+
return text;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
814
875
|
const parent = control.closest("label");
|
|
815
876
|
if (parent) {
|
|
816
877
|
const text = labelTextWithoutNested(parent);
|
|
817
878
|
if (text)
|
|
818
879
|
return text;
|
|
819
880
|
}
|
|
881
|
+
let node = control;
|
|
882
|
+
while (true) {
|
|
883
|
+
const root = node.getRootNode();
|
|
884
|
+
if (!(root instanceof ShadowRoot))
|
|
885
|
+
break;
|
|
886
|
+
const host = root.host;
|
|
887
|
+
const hostLabel = host.getAttribute("label") || host.getAttribute("aria-label");
|
|
888
|
+
if (hostLabel)
|
|
889
|
+
return hostLabel;
|
|
890
|
+
node = host;
|
|
891
|
+
}
|
|
820
892
|
return "";
|
|
821
893
|
}
|
|
822
894
|
function labelTextWithoutNested(label) {
|
|
@@ -1516,6 +1588,51 @@ function getMissingRequired(metadata, params) {
|
|
|
1516
1588
|
return [];
|
|
1517
1589
|
return metadata.inputSchema.required.filter((fieldKey) => !(fieldKey in params));
|
|
1518
1590
|
}
|
|
1591
|
+
async function fillComboboxButton(el, value) {
|
|
1592
|
+
const text = String(value ?? "").trim();
|
|
1593
|
+
console.log("[auto-webmcp] fillComboboxButton: clicking button, value=", JSON.stringify(text));
|
|
1594
|
+
el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
1595
|
+
const listbox = await new Promise((resolve) => {
|
|
1596
|
+
const deadline = Date.now() + 1e3;
|
|
1597
|
+
const poll = () => {
|
|
1598
|
+
const candidate = document.querySelector('[role="listbox"]') ?? document.querySelector('[role="option"]')?.closest('[role="listbox"]') ?? null;
|
|
1599
|
+
if (candidate) {
|
|
1600
|
+
resolve(candidate);
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
if (Date.now() >= deadline) {
|
|
1604
|
+
resolve(null);
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
setTimeout(poll, 50);
|
|
1608
|
+
};
|
|
1609
|
+
poll();
|
|
1610
|
+
});
|
|
1611
|
+
if (!listbox) {
|
|
1612
|
+
console.warn("[auto-webmcp] fillComboboxButton: listbox did not appear after 1s");
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
const options = Array.from(listbox.querySelectorAll('[role="option"]'));
|
|
1616
|
+
console.log("[auto-webmcp] fillComboboxButton: listbox has", options.length, "options");
|
|
1617
|
+
const lowerValue = text.toLowerCase();
|
|
1618
|
+
const match = options.find((opt) => {
|
|
1619
|
+
const dataValue = (opt.getAttribute("data-value") ?? "").toLowerCase();
|
|
1620
|
+
const ariaLabel = (opt.getAttribute("aria-label") ?? "").toLowerCase();
|
|
1621
|
+
const optText = (opt.textContent ?? "").trim().toLowerCase();
|
|
1622
|
+
return dataValue === lowerValue || ariaLabel === lowerValue || optText === lowerValue;
|
|
1623
|
+
});
|
|
1624
|
+
if (match) {
|
|
1625
|
+
console.log("[auto-webmcp] fillComboboxButton: clicking option", match.textContent?.trim());
|
|
1626
|
+
match.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
1627
|
+
} else {
|
|
1628
|
+
console.warn(
|
|
1629
|
+
"[auto-webmcp] fillComboboxButton: no option matched",
|
|
1630
|
+
JSON.stringify(text),
|
|
1631
|
+
"available:",
|
|
1632
|
+
options.map((o) => o.textContent?.trim())
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1519
1636
|
|
|
1520
1637
|
// src/discovery.ts
|
|
1521
1638
|
function emit(type, form, toolName) {
|
|
@@ -1583,6 +1700,17 @@ var registeredForms = /* @__PURE__ */ new WeakSet();
|
|
|
1583
1700
|
var registeredFormCount = 0;
|
|
1584
1701
|
var reAnalysisTimers = /* @__PURE__ */ new Map();
|
|
1585
1702
|
var RE_ANALYSIS_DEBOUNCE_MS = 300;
|
|
1703
|
+
var orphanRescanTimer = null;
|
|
1704
|
+
var ORPHAN_RESCAN_DEBOUNCE_MS = 500;
|
|
1705
|
+
var registeredOrphanToolNames = /* @__PURE__ */ new Set();
|
|
1706
|
+
function scheduleOrphanRescan(config) {
|
|
1707
|
+
if (orphanRescanTimer)
|
|
1708
|
+
clearTimeout(orphanRescanTimer);
|
|
1709
|
+
orphanRescanTimer = setTimeout(() => {
|
|
1710
|
+
orphanRescanTimer = null;
|
|
1711
|
+
void scanOrphanInputs(config);
|
|
1712
|
+
}, ORPHAN_RESCAN_DEBOUNCE_MS);
|
|
1713
|
+
}
|
|
1586
1714
|
function isInterestingNode(node) {
|
|
1587
1715
|
const tag = node.tagName.toLowerCase();
|
|
1588
1716
|
if (tag === "input" || tag === "textarea" || tag === "select")
|
|
@@ -1629,6 +1757,9 @@ function startObserver(config) {
|
|
|
1629
1757
|
for (const form of Array.from(node.querySelectorAll("form"))) {
|
|
1630
1758
|
void registerForm(form, config);
|
|
1631
1759
|
}
|
|
1760
|
+
if (isInterestingNode(node) && !node.closest("form")) {
|
|
1761
|
+
scheduleOrphanRescan(config);
|
|
1762
|
+
}
|
|
1632
1763
|
}
|
|
1633
1764
|
for (const node of mutation.removedNodes) {
|
|
1634
1765
|
if (!(node instanceof Element))
|
|
@@ -1676,10 +1807,10 @@ async function scanOrphanInputs(config) {
|
|
|
1676
1807
|
return;
|
|
1677
1808
|
const SUBMIT_BTN_SELECTOR = '[type="submit"]:not([disabled]), button[data-variant="primary"]:not([disabled])';
|
|
1678
1809
|
const SUBMIT_BTN_GROUPING_SELECTOR = '[type="submit"], button[data-variant="primary"]';
|
|
1679
|
-
const SUBMIT_TEXT_RE = /subscribe|submit|sign[\s-]?up|send|join|go|search|post|tweet|publish/i;
|
|
1810
|
+
const SUBMIT_TEXT_RE = /subscribe|submit|sign[\s-]?up|send|join|go|search|post|tweet|publish|save/i;
|
|
1680
1811
|
const orphanInputs = Array.from(
|
|
1681
1812
|
document.querySelectorAll(
|
|
1682
|
-
'input:not(form input), textarea:not(form textarea), select:not(form select), [role="textbox"]:not(form [role="textbox"]):not(input):not(textarea), [role="searchbox"]:not(form [role="searchbox"]):not(input):not(textarea), [contenteditable="true"]:not(form [contenteditable="true"]):not(input):not(textarea)'
|
|
1813
|
+
'input:not(form input), textarea:not(form textarea), select:not(form select), [role="textbox"]:not(form [role="textbox"]):not(input):not(textarea), [role="searchbox"]:not(form [role="searchbox"]):not(input):not(textarea), [contenteditable="true"]:not(form [contenteditable="true"]):not(input):not(textarea), button[role="combobox"]:not(form button[role="combobox"])'
|
|
1683
1814
|
)
|
|
1684
1815
|
).filter((el) => {
|
|
1685
1816
|
if (el instanceof HTMLInputElement && ORPHAN_EXCLUDED_TYPES.has(el.type.toLowerCase())) {
|
|
@@ -1810,7 +1941,11 @@ async function scanOrphanInputs(config) {
|
|
|
1810
1941
|
for (const { key, el } of inputPairs) {
|
|
1811
1942
|
if (params[key] !== void 0) {
|
|
1812
1943
|
console.log(`[auto-webmcp] orphan execute: filling key="${key}" value=`, params[key], "element=", el);
|
|
1813
|
-
|
|
1944
|
+
if (el.getAttribute("role") === "combobox" && el.tagName.toLowerCase() === "button") {
|
|
1945
|
+
await fillComboboxButton(el, params[key]);
|
|
1946
|
+
} else {
|
|
1947
|
+
fillElement(el, params[key]);
|
|
1948
|
+
}
|
|
1814
1949
|
console.log(`[auto-webmcp] orphan execute: after fill, element value=`, el.value);
|
|
1815
1950
|
} else {
|
|
1816
1951
|
console.log(`[auto-webmcp] orphan execute: key="${key}" not in params, skipping`);
|
|
@@ -1822,22 +1957,43 @@ async function scanOrphanInputs(config) {
|
|
|
1822
1957
|
console.log(`[auto-webmcp] orphan execute: autoSubmit=false, returning without clicking submit`);
|
|
1823
1958
|
return { content: [{ type: "text", text: "Fields filled. Ready to submit." }] };
|
|
1824
1959
|
}
|
|
1825
|
-
console.log(`[auto-webmcp] orphan execute:
|
|
1960
|
+
console.log(`[auto-webmcp] orphan execute: resolving submit button (up to 2s)...`);
|
|
1826
1961
|
let btn = null;
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
const
|
|
1830
|
-
|
|
1962
|
+
if (submitBtn && document.contains(submitBtn)) {
|
|
1963
|
+
const isEnabled = !submitBtn.disabled && submitBtn.getAttribute("aria-disabled") !== "true";
|
|
1964
|
+
const r = submitBtn.getBoundingClientRect();
|
|
1965
|
+
if (isEnabled && r.width > 0 && r.height > 0) {
|
|
1966
|
+
btn = submitBtn;
|
|
1967
|
+
console.log(`[auto-webmcp] orphan execute: using captured submit button "${btn.textContent?.trim()}"`);
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
if (!btn) {
|
|
1971
|
+
const deadline = Date.now() + 2e3;
|
|
1972
|
+
while (Date.now() < deadline) {
|
|
1973
|
+
const candidates = Array.from(
|
|
1974
|
+
container.querySelectorAll(SUBMIT_BTN_SELECTOR)
|
|
1975
|
+
).filter((b) => {
|
|
1976
|
+
const r = b.getBoundingClientRect();
|
|
1977
|
+
return r.width > 0 && r.height > 0;
|
|
1978
|
+
});
|
|
1979
|
+
const last = candidates[candidates.length - 1] ?? null;
|
|
1980
|
+
if (last) {
|
|
1981
|
+
btn = last;
|
|
1982
|
+
break;
|
|
1983
|
+
}
|
|
1984
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
if (!btn) {
|
|
1988
|
+
const textBtns = Array.from(
|
|
1989
|
+
(container !== document.body ? container : document).querySelectorAll('button, [role="button"]')
|
|
1831
1990
|
).filter((b) => {
|
|
1832
1991
|
const r = b.getBoundingClientRect();
|
|
1833
|
-
return r.width > 0 && r.height > 0;
|
|
1992
|
+
return r.width > 0 && r.height > 0 && !b.disabled && b.getAttribute("aria-disabled") !== "true" && SUBMIT_TEXT_RE.test(b.textContent ?? "");
|
|
1834
1993
|
});
|
|
1835
|
-
|
|
1836
|
-
if (
|
|
1837
|
-
|
|
1838
|
-
break;
|
|
1839
|
-
}
|
|
1840
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
1994
|
+
btn = textBtns[textBtns.length - 1] ?? null;
|
|
1995
|
+
if (btn)
|
|
1996
|
+
console.log(`[auto-webmcp] orphan execute: using text-matched fallback button "${btn.textContent?.trim()}"`);
|
|
1841
1997
|
}
|
|
1842
1998
|
if (!btn) {
|
|
1843
1999
|
console.warn(`[auto-webmcp] orphan execute: submit button still disabled after 2s`);
|
|
@@ -1848,6 +2004,10 @@ async function scanOrphanInputs(config) {
|
|
|
1848
2004
|
return { content: [{ type: "text", text: "Fields filled and form submitted." }] };
|
|
1849
2005
|
};
|
|
1850
2006
|
try {
|
|
2007
|
+
if (registeredOrphanToolNames.has(metadata.name)) {
|
|
2008
|
+
console.log(`[auto-webmcp] orphan: "${metadata.name}" already registered, skipping`);
|
|
2009
|
+
continue;
|
|
2010
|
+
}
|
|
1851
2011
|
const toolDef = {
|
|
1852
2012
|
name: metadata.name,
|
|
1853
2013
|
description: metadata.description,
|
|
@@ -1858,6 +2018,7 @@ async function scanOrphanInputs(config) {
|
|
|
1858
2018
|
toolDef.annotations = metadata.annotations;
|
|
1859
2019
|
}
|
|
1860
2020
|
await navigator.modelContext.registerTool(toolDef);
|
|
2021
|
+
registeredOrphanToolNames.add(metadata.name);
|
|
1861
2022
|
const pendingBtns = window["__pendingSubmitBtns"] ??= {};
|
|
1862
2023
|
pendingBtns[metadata.name] = submitBtn;
|
|
1863
2024
|
if (config.debug) {
|
|
@@ -1885,6 +2046,7 @@ async function startDiscovery(config) {
|
|
|
1885
2046
|
);
|
|
1886
2047
|
}
|
|
1887
2048
|
registeredFormCount = 0;
|
|
2049
|
+
registeredOrphanToolNames.clear();
|
|
1888
2050
|
startObserver(config);
|
|
1889
2051
|
listenForRouteChanges(config);
|
|
1890
2052
|
await scanForms(config);
|