feat: extensão de navegador para gerar CPF/CNPJ no menu de contexto

Adiciona uma extensão MV3 (Firefox e Chromium/Brave) que insere CPF ou CNPJ
válido direto no campo focado, pelo menu de contexto de qualquer input,
textarea ou contenteditable.

Extensões rodam em sandbox sem acesso a processos, então não há como invocar
os binários cpfgen/cnpjgen; fazer isso exigiria um host de native messaging
registrado no SO. Como os algoritmos são curtos e puros, foram portados para
JS em extension/generator.js, a partir de main.go (CPF) e de
cnpj_generator/cnpj/generate.go (CNPJ), trocando math/rand por
crypto.getRandomValues. test_generator.mjs compara as duas implementações
para que não divirjam.

A inserção usa document.execCommand("insertText"), que emite os eventos
nativos e portanto funciona em campos controlados por React/Vue — atribuir
.value diretamente seria descartado por esses frameworks.

Somente desktop: o Firefox para Android não implementa a API menus.

Testes carregam a extensão de verdade nos dois navegadores e verificam que os
8 itens de menu são criados e que a inserção funciona nos três tipos de campo.
This commit is contained in:
Lucas F. 2026-09-03 17:57:50 -03:00
parent 688c5a48f3
commit 7632cfde2c
14 changed files with 792 additions and 4 deletions

174
extension/test_chromium.py Normal file
View file

@ -0,0 +1,174 @@
"""Carrega a extensão no Brave/Chromium (headless) via DevTools Protocol e
confirma que o service worker MV3 sobe e registra os 8 itens de menu.
Usa apenas a stdlib: o WebSocket do CDP é falado na mão."""
import base64, json, os, shutil, socket, struct, subprocess, tempfile, time
import urllib.request
EXT = os.path.dirname(os.path.abspath(__file__))
PORT = 9333
BROWSER = next((b for b in ("brave", "chromium", "google-chrome-stable", "chrome")
if shutil.which(b)), None)
if not BROWSER:
print("nenhum navegador Chromium encontrado, pulando")
raise SystemExit(0)
profile = tempfile.mkdtemp(prefix="chromium-test-")
proc = subprocess.Popen([
BROWSER,
"--headless=new",
f"--remote-debugging-port={PORT}",
f"--user-data-dir={profile}",
f"--load-extension={EXT}",
f"--disable-extensions-except={EXT}",
"--no-first-run", "--no-default-browser-check",
"--disable-gpu",
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def cleanup():
proc.terminate()
try: proc.wait(timeout=10)
except Exception: proc.kill()
shutil.rmtree(profile, ignore_errors=True)
def http(path):
with urllib.request.urlopen(f"http://127.0.0.1:{PORT}{path}", timeout=10) as r:
return json.load(r)
class WS:
"""Cliente WebSocket minimalista, suficiente para falar CDP."""
def __init__(self, url):
_, _, rest = url.partition("://")
hostport, _, path = rest.partition("/")
host, _, port = hostport.partition(":")
self.s = socket.create_connection((host, int(port)), timeout=20)
self.s.settimeout(20)
key = base64.b64encode(os.urandom(16)).decode()
self.s.sendall((
f"GET /{path} HTTP/1.1\r\nHost: {hostport}\r\n"
"Upgrade: websocket\r\nConnection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
).encode())
buf = b""
while b"\r\n\r\n" not in buf:
buf += self.s.recv(4096)
self.buf = buf.split(b"\r\n\r\n", 1)[1]
self.mid = 0
def _frame(self, payload):
data = payload.encode()
hdr = bytearray([0x81])
mask = os.urandom(4)
n = len(data)
if n < 126:
hdr.append(0x80 | n)
elif n < 65536:
hdr.append(0x80 | 126); hdr += struct.pack(">H", n)
else:
hdr.append(0x80 | 127); hdr += struct.pack(">Q", n)
hdr += mask
hdr += bytes(b ^ mask[i % 4] for i, b in enumerate(data))
return bytes(hdr)
def _recv_exact(self, n):
while len(self.buf) < n:
self.buf += self.s.recv(65536)
out, self.buf = self.buf[:n], self.buf[n:]
return out
def _read_frame(self):
h = self._recv_exact(2)
ln = h[1] & 0x7F
if ln == 126:
ln = struct.unpack(">H", self._recv_exact(2))[0]
elif ln == 127:
ln = struct.unpack(">Q", self._recv_exact(8))[0]
return self._recv_exact(ln)
def call(self, method, params=None, session=None):
self.mid += 1
msg = {"id": self.mid, "method": method, "params": params or {}}
if session:
msg["sessionId"] = session
self.s.sendall(self._frame(json.dumps(msg)))
while True:
m = json.loads(self._read_frame())
if m.get("id") == self.mid:
if "error" in m:
raise RuntimeError(f"{method}: {m['error']}")
return m.get("result", {})
try:
for _ in range(60):
try:
ver = http("/json/version"); break
except Exception:
time.sleep(1)
else:
raise SystemExit(f"{BROWSER} não subiu")
print("browser:", ver.get("Product"))
ws = WS(ver["webSocketDebuggerUrl"])
# Espera o service worker da extensão aparecer como alvo.
target = None
for _ in range(40):
for t in ws.call("Target.getTargets")["targetInfos"]:
if t["type"] == "service_worker" and t["url"].startswith("chrome-extension://"):
target = t; break
if target: break
time.sleep(0.5)
if not target:
raise SystemExit("FALHA: service worker da extensão não iniciou")
print("service worker:", target["url"])
sess = ws.call("Target.attachToTarget",
{"targetId": target["targetId"], "flatten": True})["sessionId"]
# Os menus só existem se background.js executou até o fim. Recria um item
# com um id já usado: o erro "duplicate id" prova que o original está lá.
check = """
(async () => {
const probe = (id) => new Promise(res => {
try {
chrome.contextMenus.create({id, title: 't', contexts: ['editable']},
() => res(chrome.runtime.lastError
? chrome.runtime.lastError.message : 'CRIADO-NOVO'));
} catch (e) { res('THROW: ' + e.message); }
});
const ids = ['cpf','cnpj','cpf-formatted','cpf-raw','cnpj-formatted',
'cnpj-raw','cnpj-alpha-formatted','cnpj-alpha-raw'];
const out = {};
for (const id of ids) out[id] = await probe(id);
return JSON.stringify({
menusApi: typeof chrome.contextMenus.create === 'function',
scripting: typeof chrome.scripting.executeScript === 'function',
probes: out,
});
})().catch(e => "ERRO: " + (e && e.stack || e))
"""
r = ws.call("Runtime.evaluate",
{"expression": check, "awaitPromise": True, "returnByValue": True},
session=sess)
val = r["result"].get("value")
if not isinstance(val, str):
raise SystemExit(f"FALHA ao avaliar no SW: {json.dumps(r)[:900]}")
if val.startswith("ERRO"):
raise SystemExit(val)
data = json.loads(val)
assert data["menusApi"], "chrome.contextMenus indisponível"
assert data["scripting"], "chrome.scripting indisponível"
faltando = [i for i, r in data["probes"].items() if "duplicate" not in r.lower()]
assert not faltando, f"itens não criados pelo background: {faltando}"
print("8/8 itens de menu confirmados no Chromium")
# Checa erros do service worker no console.
errs = ws.call("Log.enable", session=sess)
print("\nOK: SW ativo, APIs disponíveis, geradores funcionando no Chromium")
finally:
cleanup()