feat: add browser automation commands (v0.6.0)
Testing / test (push) Successful in 24s
Package Extension / package-extension (push) Successful in 9s
Build & Publish Package / publish (push) Successful in 21s

Navigation: open-wait (open + block until loaded)
DOM: key, hover, check/uncheck, clear, focus, submit, poll, scroll, select, eval, wait-for
Tabs: pin/unpin, screenshot, watch-url (block until URL matches regex)
New command groups: page info, storage get/set, cookies list/get/set
Extension: add cookies permission
This commit is contained in:
2026-04-16 14:21:19 +02:00
parent fc4ce8f74d
commit 1c5fd0ffee
11 changed files with 922 additions and 9 deletions
+300 -6
View File
@@ -127,6 +127,8 @@ async function dispatch(command, args) {
case "navigate.back": return navBack(args);
case "navigate.forward": return navForward(args);
case "navigate.focus": return navFocus(args);
case "navigate.wait": return navWait(args);
case "navigate.open_wait": return navOpenWait(args);
// ── Tabs ──────────────────────────────────────────────────────────────
case "tabs.list": return tabsList();
@@ -144,6 +146,10 @@ async function dispatch(command, args) {
case "tabs.merge_windows": return tabsMergeWindows();
case "tabs.mute": return tabsMute(args);
case "tabs.unmute": return tabsUnmute(args);
case "tabs.pin": return tabsPin(args);
case "tabs.unpin": return tabsUnpin(args);
case "tabs.screenshot": return tabsScreenshot(args);
case "tabs.watch_url": return tabsWatchUrl(args);
// ── Groups ────────────────────────────────────────────────────────────
case "group.list": return groupList();
@@ -162,12 +168,36 @@ async function dispatch(command, args) {
case "windows.open": return windowsOpen(args);
// ── DOM ───────────────────────────────────────────────────────────────
case "dom.query": return domOp("domQuery", args);
case "dom.click": return domOp("domClick", args);
case "dom.type": return domOp("domType", args);
case "dom.attr": return domOp("domAttr", args);
case "dom.text": return domOp("domText", args);
case "dom.exists": return domOp("domExists", args);
case "dom.query": return domOp("domQuery", args);
case "dom.click": return domOp("domClick", args);
case "dom.type": return domOp("domType", args);
case "dom.attr": return domOp("domAttr", args);
case "dom.text": return domOp("domText", args);
case "dom.exists": return domOp("domExists", args);
case "dom.scroll": return domOp("domScroll", args);
case "dom.select": return domOp("domSelect", args);
case "dom.key": return domOp("domKey", args);
case "dom.hover": return domOp("domHover", args);
case "dom.check": return domOp("domCheck", { ...args, checked: true });
case "dom.uncheck": return domOp("domCheck", { ...args, checked: false });
case "dom.clear": return domOp("domClear", args);
case "dom.focus": return domOp("domFocus", args);
case "dom.submit": return domOp("domSubmit", args);
case "dom.eval": return domEval(args);
case "dom.wait_for": return domWaitFor(args);
case "dom.poll": return domPoll(args);
// ── Page ──────────────────────────────────────────────────────────────
case "page.info": return domOp("pageInfo", {});
// ── Storage ───────────────────────────────────────────────────────────
case "storage.get": return storageGet(args);
case "storage.set": return storageSet(args);
// ── Cookies ───────────────────────────────────────────────────────────
case "cookies.list": return cookiesList(args);
case "cookies.get": return cookiesGet(args);
case "cookies.set": return cookiesSet(args);
// ── Extract ───────────────────────────────────────────────────────────
case "extract.links": return domOp("extractLinks", args);
@@ -267,6 +297,25 @@ async function navFocus({ pattern }) {
return { id: match.id, url: match.url || match.pendingUrl, title: match.title };
}
async function navWait({ tabId, timeout = 30000, readyState = "complete" } = {}) {
const tab = tabId ? { id: tabId } : await getActiveTab();
const deadline = Date.now() + timeout;
const interval = 200;
while (Date.now() < deadline) {
const t = await chrome.tabs.get(tab.id);
if (readyState === "complete" ? t.status === "complete" : t.status !== "loading") {
return tabInfo(t);
}
await new Promise(r => setTimeout(r, interval));
}
throw new Error(`Tab ${tab.id} did not reach status '${readyState}' within ${timeout}ms`);
}
async function navOpenWait({ url, timeout = 30000, background, window: windowName, group } = {}) {
const opened = await navOpen({ url, background, window: windowName, group });
return await navWait({ tabId: opened.id, timeout });
}
// ── Tabs ──────────────────────────────────────────────────────────────────────
async function tabsList() {
@@ -442,6 +491,47 @@ async function tabsMergeWindows() {
return { moved };
}
async function tabsPin({ tabId }) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
await chrome.tabs.update(tab.id, { pinned: true });
return { tabId: tab.id, pinned: true };
}
async function tabsUnpin({ tabId }) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
await chrome.tabs.update(tab.id, { pinned: false });
return { tabId: tab.id, pinned: false };
}
async function tabsScreenshot({ tabId, format = "png", quality } = {}) {
let windowId;
if (tabId) {
const tab = await chrome.tabs.get(tabId);
await chrome.tabs.update(tabId, { active: true });
windowId = tab.windowId;
} else {
const tab = await getActiveTab();
windowId = tab.windowId;
}
const opts = { format };
if (format === "jpeg" && quality != null) opts.quality = quality;
const dataUrl = await chrome.tabs.captureVisibleTab(windowId, opts);
return { dataUrl, format };
}
async function tabsWatchUrl({ pattern, timeout = 30000, tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
const deadline = Date.now() + timeout;
const regex = new RegExp(pattern);
while (Date.now() < deadline) {
const t = await chrome.tabs.get(tab.id);
const url = t.url || t.pendingUrl || "";
if (regex.test(url)) return tabInfo(t);
await new Promise(r => setTimeout(r, 200));
}
throw new Error(`Tab ${tab.id} URL did not match '${pattern}' within ${timeout}ms`);
}
async function tabsMute({ tabId }) {
const tab = await resolveTabForDirectAction(tabId, "mute");
await chrome.tabs.update(tab.id, { muted: true });
@@ -616,6 +706,131 @@ async function domOp(funcName, args) {
return results[0]?.result;
}
async function domEval({ code, tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
}
const results = await executeScript({
target: { tabId: tab.id },
world: "MAIN",
func: (c) => (0, eval)(c),
args: [code],
});
return results[0]?.result ?? null;
}
async function domWaitFor({ selector, timeout = 10000, visible = false, hidden = false, tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
}
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const results = await executeScript({
target: { tabId: tab.id },
func: (sel, vis, hid) => {
const el = document.querySelector(sel);
if (hid) return !el || el.offsetParent === null;
if (!el) return false;
if (vis) {
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
}
return true;
},
args: [selector, visible, hidden],
});
if (results[0]?.result) return { selector, found: !hidden };
await new Promise(r => setTimeout(r, 200));
}
throw new Error(`Selector '${selector}' condition not met within ${timeout}ms`);
}
async function domPoll({ selector, pattern, attr, timeout = 30000, interval = 500, tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
}
const deadline = Date.now() + timeout;
const regex = new RegExp(pattern);
while (Date.now() < deadline) {
const results = await executeScript({
target: { tabId: tab.id },
func: (sel, a) => {
const el = document.querySelector(sel);
if (!el) return null;
if (a) return el.getAttribute(a) ?? el[a] ?? null;
return el.value !== undefined ? el.value : el.textContent.trim();
},
args: [selector, attr || null],
});
const value = results[0]?.result;
if (value != null && regex.test(String(value))) return { selector, value, pattern };
await new Promise(r => setTimeout(r, interval));
}
throw new Error(`Selector '${selector}' did not match '${pattern}' within ${timeout}ms`);
}
async function storageGet({ key, type = "local", tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot access storage on ${tab.url} — navigate to a regular web page first`);
}
const results = await executeScript({
target: { tabId: tab.id },
world: "MAIN",
func: (k, t) => {
const store = t === "session" ? sessionStorage : localStorage;
if (k) return store.getItem(k);
return Object.fromEntries(Object.keys(store).map(key => [key, store.getItem(key)]));
},
args: [key || null, type],
});
return results[0]?.result ?? null;
}
async function storageSet({ key, value, type = "local", tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot access storage on ${tab.url} — navigate to a regular web page first`);
}
const results = await executeScript({
target: { tabId: tab.id },
world: "MAIN",
func: (k, v, t) => {
const store = t === "session" ? sessionStorage : localStorage;
store.setItem(k, typeof v === "string" ? v : JSON.stringify(v));
return true;
},
args: [key, value, type],
});
return results[0]?.result ?? false;
}
async function cookiesList({ url, domain, name } = {}) {
const details = {};
if (url) details.url = url;
if (domain) details.domain = domain;
if (name) details.name = name;
return await chrome.cookies.getAll(details);
}
async function cookiesGet({ url, name }) {
return await chrome.cookies.get({ url, name });
}
async function cookiesSet({ url, name, value, domain, path, secure, httpOnly, expirationDate, sameSite } = {}) {
const details = { url, name, value };
if (domain != null) details.domain = domain;
if (path != null) details.path = path;
if (secure != null) details.secure = secure;
if (httpOnly != null) details.httpOnly = httpOnly;
if (expirationDate != null) details.expirationDate = expirationDate;
if (sameSite != null) details.sameSite = sameSite;
return await chrome.cookies.set(details);
}
// This function is serialized and injected into the page by chrome.scripting
function contentDispatch(funcName, args) {
function domQuery({ selector }) {
@@ -653,6 +868,83 @@ function contentDispatch(funcName, args) {
function domExists({ selector }) {
return document.querySelector(selector) !== null;
}
function domKey({ selector, key }) {
const el = selector ? document.querySelector(selector) : document.activeElement;
if (selector && !el) throw new Error(`No element: ${selector}`);
const target = el || document.body;
["keydown", "keypress", "keyup"].forEach(type => {
target.dispatchEvent(new KeyboardEvent(type, { key, bubbles: true, cancelable: true }));
});
return true;
}
function domHover({ selector }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
el.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
return true;
}
function domCheck({ selector, checked }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
el.checked = checked;
el.dispatchEvent(new Event("change", { bubbles: true }));
return true;
}
function domClear({ selector }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
el.value = "";
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
return true;
}
function domFocus({ selector }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
el.focus();
return true;
}
function domSubmit({ selector }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
const form = el.tagName === "FORM" ? el : el.closest("form");
if (!form) throw new Error(`No form found for: ${selector}`);
form.submit();
return true;
}
function pageInfo() {
const metas = {};
document.querySelectorAll("meta[name], meta[property]").forEach(m => {
const k = m.getAttribute("name") || m.getAttribute("property");
if (k) metas[k] = m.getAttribute("content") || "";
});
return {
title: document.title,
url: location.href,
readyState: document.readyState,
lang: document.documentElement.lang || null,
meta: metas,
};
}
function domScroll({ selector, x, y }) {
if (selector) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
el.scrollIntoView({ behavior: "smooth", block: "center" });
return true;
}
window.scrollTo({ top: y || 0, left: x || 0, behavior: "smooth" });
return true;
}
function domSelect({ selector, value }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
el.value = value;
el.dispatchEvent(new Event("change", { bubbles: true }));
el.dispatchEvent(new Event("input", { bubbles: true }));
return true;
}
function extractLinks() {
const seen = new Set();
return Array.from(document.querySelectorAll("a[href]")).reduce((links, a) => {
@@ -1090,6 +1382,8 @@ function contentDispatch(funcName, args) {
}
const fns = { domQuery, domClick, domType, domAttr, domText, domExists,
domScroll, domSelect, domKey, domHover, domCheck, domClear, domFocus, domSubmit,
pageInfo,
extractLinks, extractImages, extractText, extractJson, extractMarkdown };
const fn = fns[funcName];
if (!fn) throw new Error(`Unknown content function: ${funcName}`);