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

141
extension/test_extension.py Normal file
View file

@ -0,0 +1,141 @@
"""Carrega a extensão num Firefox headless via Marionette, confirma que o
background script inicializou e que os itens de menu foram criados, e exercita
insert.js contra um input real, um textarea e um contenteditable."""
import json, os, socket, subprocess, tempfile, time, shutil, sys
EXT = os.path.dirname(os.path.abspath(__file__))
PORT = 2929
class M:
def __init__(self, port):
self.s = socket.create_connection(("127.0.0.1", port), timeout=60)
self.s.settimeout(60)
self.buf = b""
self._read() # handshake
self.msgid = 0
def _read(self):
while b":" not in self.buf:
self.buf += self.s.recv(65536)
n, _, rest = self.buf.partition(b":")
n = int(n)
while len(rest) < n:
rest += self.s.recv(65536)
self.buf = rest[n:]
return json.loads(rest[:n])
def cmd(self, name, params=None):
self.msgid += 1
payload = json.dumps([0, self.msgid, name, params or {}]).encode()
self.s.sendall(str(len(payload)).encode() + b":" + payload)
while True:
msg = self._read()
if isinstance(msg, list) and msg[0] == 1 and msg[1] == self.msgid:
if msg[2]:
raise RuntimeError(f"{name}: {msg[2]}")
return msg[3]
if not shutil.which("firefox"):
print("firefox não encontrado, pulando")
raise SystemExit(0)
profile = tempfile.mkdtemp(prefix="ffprof-")
with open(os.path.join(profile, "user.js"), "w") as f:
f.write('user_pref("xpinstall.signatures.required", false);\n')
f.write('user_pref("extensions.autoDisableScopes", 0);\n')
f.write('user_pref("marionette.port", %d);\n' % PORT)
f.write('user_pref("browser.shell.checkDefaultBrowser", false);\n')
proc = subprocess.Popen(
["firefox", "--marionette", "-remote-allow-system-access", "--headless", "--no-remote", "--profile", profile],
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)
try:
for _ in range(60):
try:
m = M(PORT); break
except OSError:
time.sleep(1)
else:
raise SystemExit("marionette não subiu")
m.cmd("WebDriver:NewSession", {"capabilities": {}})
res = m.cmd("Addon:Install", {"path": EXT, "temporary": True})
addon_id = res.get("value", res)
print("extensão instalada:", addon_id)
time.sleep(3)
# Página de teste com os três tipos de campo editável.
html = "file://" + os.path.join(EXT, "testdata.html")
m.cmd("WebDriver:Navigate", {"url": html})
# Injeta insert.js no conteúdo e valida cada alvo.
src = open(os.path.join(EXT, "insert.js")).read()
script = src + """
const out = [];
for (const id of ["a","b","c"]) {
const el = document.getElementById(id);
el.focus();
if (el.isContentEditable) {
const r = document.createRange();
r.selectNodeContents(el); r.collapse(false);
const s = getSelection(); s.removeAllRanges(); s.addRange(r);
}
const ok = insertIntoFocused("123.456.789-09");
const val = el.isContentEditable ? el.textContent : el.value;
out.push(id + ":" + ok + ":" + val);
}
return out.join("|");
"""
r = m.cmd("WebDriver:ExecuteScript", {"script": script, "args": [], "newSandbox": True})
inserted = r.get("value")
print("inserção:", inserted)
for part in inserted.split("|"):
field, ok, value = part.split(":", 2)
assert ok == "true", f"insert falhou em #{field}"
assert "123.456.789-09" in value, f"valor ausente em #{field}: {value}"
# Confere, no processo da extensão, que os menus foram registrados.
m.cmd("Marionette:SetContext", {"value": "chrome"})
probe = """
const { ExtensionParent } = ChromeUtils.importESModule(
"resource://gre/modules/ExtensionParent.sys.mjs");
const pol = WebExtensionPolicy.getByID("cpfgen@lucasf.xyz");
if (!pol) return "SEM POLICY";
const ctx = ExtensionParent.GlobalManager.getExtension("cpfgen@lucasf.xyz");
const mm = ctx && ctx.apiManager;
const menus = ChromeUtils.importESModule(
"resource://gre/modules/ExtensionParent.sys.mjs");
let ids = [];
try {
const m = ctx.apiManager.global.gMenuMap;
const ext = [...m.keys()].find(e => e.id === "cpfgen@lucasf.xyz");
ids = ext ? [...m.get(ext).keys()] : [];
} catch (e) { ids = ["ERRO:" + e.message]; }
return JSON.stringify({active: pol.active, bg: !!ctx, menus: ids});
"""
r2 = m.cmd("WebDriver:ExecuteScript", {"script": probe, "args": []})
print("estado:", r2.get("value"))
state = json.loads(r2.get("value"))
assert state["active"] and state["bg"], "extensão não ficou ativa"
esperados = {
"cpf", "cnpj",
"cpf-formatted", "cpf-raw",
"cnpj-formatted", "cnpj-raw",
"cnpj-alpha-formatted", "cnpj-alpha-raw",
}
faltando = esperados - set(state["menus"])
assert not faltando, f"itens de menu ausentes: {faltando}"
print("\nOK: extensão ativa, 8 itens de menu, inserção nos 3 tipos de campo")
finally:
cleanup()