Plugin / Docs / Recipes

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.

v7.10.0
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>
Allow-list needed: open the extension popup and add 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.com is 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)

CodeMeaningWhat to do
DOMAIN_NOT_ALLOWEDHost not on Allow-listShow the banner, user adds host, then retry
TIMEOUTElement never appearedIncrease timeoutMs, verify the selector, or wait for a different marker
ELEMENT_NOT_FOUNDBad/timing-sensitive selectorUse waitFor, stabilize the selector
INVALID_URLBad scheme / not HTTPS (LAN/localhost disabled by default)Use an absolute https:// URL; for intranet see LAN page
FORBIDDENSensitive field (password, etc.)Don’t read/write secrets; require manual steps
EXT_NOT_DETECTEDExtension not found/readyInstall/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 / LocalhostHow 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.