Recipes & Copy-Paste Flows
Each recipe explains why we do the step, what you should see when it succeeds, and how to troubleshoot when it doesn’t.
Prereqs: extension installed, target domain on the Allow-list, and the shared ask() helper included.
Quick glossary (2 minutes)
- Admin: your page (this docs site). It sends JSON messages.
- Remote tab: the controlled browser tab (a real tab the user sees).
ask(action, args): send a request (e.g.,ask('navigate',{url,...})) and await{ ok, data }or{ ok:false, error }.- Allow-list: user-approved domains (privacy-first, deny-by-default).
- DOM action: run an action inside the remote page (click, type, select, submit…).
- Multi-tab: pass a
tabKey(e.g.,'auth','sales') to target a specific tab.
1) Prepare the ask() helper
Why: every recipe uses it. What you’ll see: structured responses; add your own console logs if desired. Troubleshooting: if you get “timeout”, the extension didn’t reply (not installed, blocked tab, or missing Allow-list).
<script>
// Canonical helper for v7.10.0
const ADMIN_ORIGIN='admin', EXT_ORIGIN='extension';
let _seq=0, _waiters=new Map();
addEventListener('message', (e) => {
const d=e.data; if(!d||typeof d!=='object') return;
if(d.origin!==EXT_ORIGIN) return;
if(d.requestId && _waiters.has(d.requestId)) { _waiters.get(d.requestId)(d); _waiters.delete(d.requestId); }
});
function ask(action, args={}, timeoutMs=12000){
const requestId='r'+(++_seq);
postMessage({ origin:ADMIN_ORIGIN, type:'RTO_REQUEST', requestId, action, args }, '*');
return new Promise((resolve,reject)=>{
const t=setTimeout(()=>{ _waiters.delete(requestId); reject(Object.assign(new Error('timeout'),{ code:'TIMEOUT' })); }, timeoutMs);
_waiters.set(requestId, (resp)=>{ clearTimeout(t); resolve(resp); });
});
}
// Optional: exponential backoff
async function askBackoff(action, args={}, tries=3, base=300){
let n=0;
while(true){
try {
const r = await ask(action, args, 12000);
if (!r?.ok) throw Object.assign(new Error(r?.error?.message||'error'), r?.error||{});
return r;
} catch(e) {
if (++n >= tries) throw e;
await new Promise(r => setTimeout(r, base*Math.pow(2,n-1)));
}
}
}
function showAllowFor(url){
try{
const host=new URL(url).host;
const box=document.getElementById('allow-box'); if (!box) return;
box.querySelector('.domain').textContent=host;
box.classList.remove('d-none');
}catch(_){}
}
</script>
example.com.
Then click .
2) Resilient login (idempotent)
Why: start from a known URL (e.g., /login) but don’t fail if you’re already signed in (redirect to /dashboard).
Before you start:
- Extension installed and detected (see “Detect” page).
- Domain
app.example.comis Allow-listed. - The shared
ask()helper is included.
What you’ll see: remote tab navigates to /login, fields get filled, click “Sign in”, then a .ready badge appears.
Troubleshooting: DOMAIN_NOT_ALLOWED ⇒ add the host via the extension popup, then retry. ELEMENT_NOT_FOUND ⇒ selector isn’t there yet: raise timeoutMs or verify the selector.
// 1) Go to /login (don’t steal focus) on a named tab
const url='https://app.example.com/login';
try{
await askBackoff('navigate', { url, focus:false, tabKey:'auth' });
}catch(e){
if ((e.code||'')==='DOMAIN_NOT_ALLOWED'){ showAllowFor(url); throw e; }
throw e;
}
// 2) Wait for either the login form or a "ready" badge (already signed in)
const seen = await Promise.race([
ask('waitFor', { selector:'#email', timeoutMs:15000, tabKey:'auth' }).then(_=>'form'),
ask('waitFor', { selector:'.badge.ready', timeoutMs:15000, tabKey:'auth' }).then(_=>'ready')
]).catch(_=>null);
// 3) If we saw the form, fill it and submit
if (seen==='form'){
await ask('domSetValue', { selector:'#email', value:'user@example.com', tabKey:'auth' });
await ask('domSetValue', { selector:'#note', value:'hello world', tabKey:'auth' });
await ask('domClick', { selector:'button[type=submit]', tabKey:'auth' });
// Wait for a post-login marker
await ask('waitFor', { selector:'.badge.ready', timeoutMs:30000, tabKey:'auth' });
}
// 4) Optionally bring the remote tab to front when needed
await ask('focus', { tabKey:'auth' });
3) Make sure you’re on the Dashboard (soft-correct if not)
Why: verify the target page before doing DOM work (URL and/or title).
What you’ll see: if URL isn’t correct, the remote tab navigates to /dashboard, then the title matches “Dashboard”.
Troubleshooting: if title doesn’t match, check casing/regex. If navigation fails ⇒ Allow-list or INVALID_URL.
const u = await ask('getUrl', { tabKey:'auth' }).catch(_=>null);
if (!u?.ok || !u.data?.url?.startsWith('https://app.example.com/dashboard')) {
try{
await askBackoff('navigate', { url:'https://app.example.com/dashboard', focus:false, tabKey:'auth' });
}catch(e){
if ((e.code||'')==='DOMAIN_NOT_ALLOWED'){ showAllowFor('https://app.example.com/dashboard'); throw e; }
else throw e;
}
}
const t = await ask('runJs', { code:'document.title', tabKey:'auth' });
if (!t?.ok || !/Dashboard/i.test(String(t.data||''))) throw new Error('Unexpected page title');
4) Wait for a table then paginate (SPA-friendly)
Why: in SPAs, rows render later. Wait for a real signal (≥1 row) before acting.
What you’ll see: console prints row count per page, then clicks “Next” while available.
Troubleshooting: adjust the table selector (table.data) and the “Next” button selector. Increase delays for slow apps.
// Wait until the table has ≥ 1 row
async function waitTableRows(selector='table.data', timeout=30000, step=600){
const t0 = Date.now();
while(Date.now()-t0 < timeout){
const has = await ask('runJs', { code:`(function(){
const t=document.querySelector('${selector}');
return !!(t && t.tBodies[0] && t.tBodies[0].rows.length);
})()`, tabKey:'sales' });
if (has?.ok && has.data===true) return true;
await new Promise(r=>setTimeout(r, step));
}
throw Object.assign(new Error('TIMEOUT'), { code:'TIMEOUT' });
}
await waitTableRows();
// Paginate politely (max 5 pages here)
for (let i=0;i<5;i++){
// Simple extraction
const count = await ask('runJs', { code:`(function(){
const t=document.querySelector('table.data'); return t ? (t.tBodies[0]?.rows.length||0) : 0;
})()`, tabKey:'sales' });
console.log('Rows on page', i+1, ':', count?.ok ? count.data : 0);
// If a non-disabled Next button exists, click it
const hasNext = await ask('runJs', { code:`(function(){
const n=document.querySelector('button.next:not([disabled])'); return !!n;
})()`, tabKey:'sales' });
if (!hasNext?.ok || !hasNext.data) break;
await ask('domClick', { selector:'button.next', tabKey:'sales' });
await new Promise(r => setTimeout(r, 500)); // small pause
}
5) Filters & Selects (value/label/index)
Why: many pages filter via <select>. We show two practical ways to choose an option + a shortcut.
What you’ll see: the select changes to “Active”, then we click “Apply” and wait for a .results-ready marker.
Troubleshooting: if nothing updates, it’s often a missing change event. RTO actions dispatch input/change for you.
await ask('selectSetValue', {
selector:'select#status', value:'active', tabKey:'sales'
});
await ask('domClick', { selector:'#applyFilters', tabKey:'sales' });
await ask('waitFor', { selector:'.results-ready', timeoutMs:20000, tabKey:'sales' });
// If you need to select by visible label:
await ask('runJs', { code:`(function(){
const s=document.querySelector('select#status'); if(!s) return false;
const opt=[...s.options].find(o=>o.textContent.trim()==='Active'); if(!opt) return false;
s.value=opt.value; s.dispatchEvent(new Event('input',{bubbles:true}));
s.dispatchEvent(new Event('change',{bubbles:true}));
return true;
})()`, tabKey:'sales' });
6) Checkboxes & radios (idempotent)
Why: put the element in the desired state (checked/unchecked) without blindly toggling.
Tip: if the UI library hides the input, click its label or use a small runJs that dispatches change.
await ask('runJs', { code:`(function(){
const el=document.querySelector('#tos'); if(!el) return false;
if (!el.checked){ el.checked=true; el.dispatchEvent(new Event('change',{bubbles:true})); }
return true;
})()`, tabKey:'sales' });
7) Inputs inside Shadow DOM
Why: some Web Components keep inputs inside a shadowRoot.
What you’ll see: the internal input value is set and an input event fires.
await ask('runJs', { code:`(function(){
const host=document.querySelector('my-input'); if(!host||!host.shadowRoot) return false;
const input=host.shadowRoot.querySelector('input'); if(!input) return false;
input.value='hello'; input.dispatchEvent(new Event('input',{bubbles:true}));
return true;
})()`, tabKey:'sales' });
8) Playlists (visit a list of URLs)
Why: handy for content reviews or QA checks.
What you’ll see: each page is visited, we wait for body, grab the <h1>, then continue.
Troubleshooting: if DOMAIN_NOT_ALLOWED appears mid-loop, show the banner and stop the playlist.
const pages=['/a','/b','/c'].map(x=>'https://example.com'+x);
for (const url of pages){
try{
await askBackoff('navigate', { url, tabKey:'sales' });
await ask('waitFor', { selector:'body', timeoutMs:15000, tabKey:'sales' });
const h1 = await ask('runJs', { code:'(document.querySelector("h1")||{}).textContent||""', tabKey:'sales' });
console.log('Visited:', url, 'Title:', h1?.ok ? h1.data : '');
}catch(e){
if ((e.code||'')==='DOMAIN_NOT_ALLOWED'){ showAllowFor(url); break; }
else { console.warn('Step failed', e); }
}
await new Promise(r => setTimeout(r, 600)); // be polite with the target app
}
9) Error crib sheet (most common)
| Code | Meaning | What to do |
|---|---|---|
DOMAIN_NOT_ALLOWED | Host not on Allow-list | Show the banner, user adds host, then retry |
TIMEOUT | Element never appeared | Increase timeoutMs, verify the selector, or wait for a different marker |
ELEMENT_NOT_FOUND | Bad/timing-sensitive selector | Use waitFor, stabilize the selector |
INVALID_URL | Bad scheme / not HTTPS (LAN/localhost disabled by default) | Use an absolute https:// URL; for intranet see LAN page |
FORBIDDEN | Sensitive field (password, etc.) | Don’t read/write secrets; require manual steps |
EXT_NOT_DETECTED | Extension not found/ready | Install/enable, reload, and re-run detection |
Next Pages
| Page | Description |
|---|---|
| Start | Overview, how it works, setup, quick start, and links to all sections. |
| Detect the Extension | Detection contract (ping/pong), install prompts, and graceful fallbacks when the extension isn’t present. |
| Allow-list & Permissions | Deny-by-default model, domain allow-list via popup, and suggested UX to guide users to enable hosts. |
| LAN / Localhost | How to safely enable intranet/localhost (flags + allow-list). |
| Remote Tab Control | Open/navigate/focus the controlled tab, read URL/title, select an existing tab, lifecycle & restore patterns. |
| DOM & Automation | Action catalog (type, setValue, click, select, submit, waitFor) and safe interaction tips. |
| Recipes & Flows | Copy-paste flows: resilient login, dashboard checks, table waits, filters, playlists, and allow-list banners. |
| Events | Event stream reference, subscription helpers, live logger widget, and basic metrics/durations collection. |
| Diagnostics | Inline console, error cookbook, QA self-check, and performance tips for troubleshooting flows quickly. |
| Full API Reference | Canonical message envelopes, request/response schemas, error codes, and return shapes for every action. |
| Compatibility | Supported browsers/versions, permissions nuances, CSP/iframe notes, and known limitations or workarounds. |
| Advanced Patterns | Idempotent flows, retries/backoff, state restore after reload, playlist strategies, and robust selectors. |
| Favorites | Star and group URLs, quick actions (open/focus/navigate), export/import sets, and playlist runs from favorites. |
| Real-World Flows (E2E) | Run end-to-end flows from your admin page: helper setup, modern message contract, copy-paste journeys (login, multi-tab, extract), lightweight assertions, best practices, and troubleshooting. |