Ctrl K
ComunidadGuías…

__tests__/gb-frames.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// gb-frames.test.js — the phone's page script reaches into frames (2026-11-10).
//
// A Claude session could not edit a Google Business Profile on the owner's phone: Google's edit form is a
// frame from another domain, and the phone's tools only ran in the top page. Now gb.js + a frame agent run in
// every frame (FrameBridge.kt); the top page tells each frame where it sits, a frame numbers its elements
// after the top page's and answers only the app's own channel. These run that code in jsdom.
import { describe, it, expect, beforeEach } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { JSDOM } from 'jsdom';

const read = (p) => readFileSync(fileURLToPath(new URL(p, import.meta.url)), 'utf8');
const GB = read('../mobile/app/src/main/assets/gb.js');
const KT = read('../mobile/app/src/main/java/engineer/myapp/gbmobile/FrameBridge.kt');
const FRAME_JS = KT.match(/const val FRAME_JS = """([\s\S]*?)"""/)[1];

const tick = () => new Promise((r) => setTimeout(r, 20));
function rect(el, x, y, w, h) {
  el.getBoundingClientRect = () => ({ left: x, top: y, right: x + w, bottom: y + h, width: w, height: h, x, y });
}

let dom, top, frame, sent, onMsg;
beforeEach(async () => {
  dom = new JSDOM('<!doctype html><body><button id="b">Top</button><iframe id="f"></iframe></body>', { runScripts: 'outside-only', pretendToBeVisual: true });
  top = dom.window;
  const ifr = top.document.getElementById('f');
  rect(ifr, 10, 100, 300, 400);
  rect(top.document.getElementById('b'), 0, 0, 80, 30);
  frame = ifr.contentWindow;
  // jsdom leaves event.source empty ("TODO: requires the incumbent window"); deliver it with its sender.
  const deliver = (data, source) => setTimeout(() => frame.dispatchEvent(new frame.MessageEvent('message', { data, source })), 0);
  frame.postMessage = (data) => deliver(data, top);
  frame.__fromStranger = (data) => deliver(data, frame);
  for (const w of [top, frame]) Object.defineProperty(w.HTMLElement.prototype, 'innerText', { configurable: true, get() { return this.textContent; } });
  frame.document.body.innerHTML = '<input id="name" placeholder="Bedrijfsnaam"><button id="save">Opslaan</button><p>Profiel bewerken</p>';
  rect(frame.document.getElementById('name'), 5, 20, 200, 30);
  rect(frame.document.getElementById('save'), 5, 60, 90, 30);
  sent = []; onMsg = null;
  frame.GBFrame = { postMessage: (s) => sent.push(JSON.parse(s)), addEventListener: (t, fn) => { if (t === 'message') onMsg = fn; } };
  frame.eval(GB); frame.eval(FRAME_JS);
  top.eval(GB);
});
const ask = (m) => { onMsg({ data: JSON.stringify({ rid: 'r1', ...m }) }); return sent[sent.length - 1]; };

describe('a frame on the phone', () => {
  it('says hello to the app over its own channel, never the top page', () => {
    expect(sent[0]).toMatchObject({ hello: 1 });
    expect(sent[0].fid).toMatch(/^[a-z0-9]{8,}$/);
    expect(top.GBFrame).toBeUndefined();
  });

  it('does not know where it is until the page above tells it', async () => {
    expect(ask({ op: 'where' }).result.off).toBeNull();
    expect(ask({ op: 'mark' }).result).toMatchObject({ hidden: true });
    expect(top.__gb.geo()).toMatchObject({ frames: 1 });
    await tick();
    expect(ask({ op: 'where' }).result.off).toEqual({ x: 10, y: 100 });
  });

  it('ignores a position from anyone but its own parent', async () => {
    frame.__fromStranger({ __gbgeo: 1, x: 999, y: 999 });
    await tick();
    expect(ask({ op: 'where' }).result.off).toBeNull();
  });

  it('numbers its elements after the top page and reads its own form', async () => {
    const topEls = top.__gb.mark();
    expect(topEls.map((e) => e.text)).toContain('Top');
    top.__gb.geo(); await tick();
    const r = ask({ op: 'mark', base: topEls.length }).result;
    expect(r.off).toEqual({ x: 10, y: 100 });
    expect(r.els.map((e) => e.text)).toEqual(['Bedrijfsnaam', 'Opslaan']);
    const badges = [...frame.document.querySelectorAll('#__gbmarks > div')].map((d) => d.textContent).filter(Boolean);
    expect(badges).toEqual([String(topEls.length), String(topEls.length + 1)]);
  });

  it('types into the frame’s field and reads the frame’s text', async () => {
    top.__gb.geo(); await tick(); ask({ op: 'mark' });
    expect(ask({ op: 'type', i: 0, text: 'my-app.engineer' }).result).toBe('ok');
    expect(frame.document.getElementById('name').value).toBe('my-app.engineer');
    expect(ask({ op: 'text' }).result.text).toContain('Profiel bewerken');
  });

  it('does not number what is hidden under the frame (the page behind a dialog)', () => {
    const d = top.document;
    const under = d.createElement('a'); under.href = '/zoeken'; under.textContent = 'Zoekresultaat'; d.body.appendChild(under);
    rect(under, 50, 200, 120, 30);
    top.innerWidth = 400; top.innerHeight = 800;
    const ifr = d.getElementById('f'), btn = d.getElementById('b');
    d.elementFromPoint = (x, y) => (x >= 10 && x < 310 && y >= 100 && y < 500 ? ifr : x < 80 && y < 30 ? btn : d.body);
    const texts = top.__gb.mark().map((e) => e.text);
    expect(texts).toContain('Top');
    expect(texts).not.toContain('Zoekresultaat');
  });

  it('selects a field before real keys and reports what it holds', async () => {
    top.__gb.geo(); await tick(); ask({ op: 'mark' });
    frame.document.getElementById('name').value = 'Oude naam';
    expect(ask({ op: 'select', i: 0 }).result).toBe(9);
    expect(frame.document.activeElement.id).toBe('name');
    expect(ask({ op: 'value', i: 0 }).result).toBe('Oude naam');
    expect(ask({ op: 'select', i: 99 }).result).toBe(-1);
  });

  it('answers only a command addressed to it', () => {
    const before = sent.length;
    onMsg({ data: JSON.stringify({ rid: 'r2', fid: 'someone-else', op: 'where' }) });
    expect(sent.length).toBe(before);
  });
});