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

3
.gitignore vendored
View file

@ -39,3 +39,6 @@ go.work.sum
# Editor/IDE # Editor/IDE
# .idea/ # .idea/
# .vscode/ # .vscode/
# extensão de navegador
web-ext-artifacts/

View file

@ -1,10 +1,31 @@
.PHONY: build test clean .PHONY: build test clean ext-test ext-lint ext-build ext-run
build: build:
@mkdir -p bin && go build -o bin/cpfgen . @mkdir -p bin && go build -ldflags="-s -w" -o bin/cpfgen .
test: test:
@go test -v ./... @go test -v ./...
clean: clean:
@rm -rf bin @rm -rf bin web-ext-artifacts
# --- extensão de navegador ---
# Confere o porte JS contra os binários Go e carrega a extensão nos dois
# navegadores para validar menus e inserção de verdade.
ext-test:
@node extension/test_generator.mjs
@python3 extension/test_extension.py
@python3 extension/test_chromium.py
ext-lint:
@npx --yes web-ext@latest lint --source-dir extension
# Gera o .zip para empacotar/publicar, sem os testes nem o SVG fonte.
ext-build:
@npx --yes web-ext@latest build --source-dir extension --overwrite-dest \
--ignore-files 'test_*' 'testdata.html' 'icons/icon.svg'
# Abre um Firefox temporário com a extensão já carregada.
ext-run:
@npx --yes web-ext@latest run --source-dir extension

View file

@ -1,6 +1,7 @@
# cpfgen # cpfgen
CLI para gerar CPFs válidos em Go. CLI para gerar CPFs válidos em Go, mais uma extensão de navegador que gera
CPF e CNPJ pelo menu de contexto de qualquer campo de texto.
## Instalação ## Instalação
@ -20,3 +21,75 @@ make test
./bin/cpfgen # 487.197.991-10 ./bin/cpfgen # 487.197.991-10
./bin/cpfgen -num # 48719799110 ./bin/cpfgen -num # 48719799110
``` ```
## Extensão de navegador
Em `extension/`. Clique com o botão direito em qualquer `input`, `textarea` ou
campo `contenteditable` e escolha **Gerar CPF** ou **Gerar CNPJ** — o valor é
inserido direto no campo, na posição do cursor.
Cada menu oferece formatado e só números; o CNPJ inclui também as duas variantes
alfanuméricas (formato novo).
A extensão **não** chama os binários Go: extensões rodam num sandbox sem acesso
a processos do sistema, e fazer isso exigiria um host de native messaging
registrado no sistema operacional. Como os algoritmos são curtos e puros, eles
foram portados para JS em `extension/generator.js`, a partir de `main.go` (CPF) e
de `cnpj_generator/cnpj/generate.go` (CNPJ). `make ext-test` compara as duas
implementações para garantir que não divirjam.
Não pede permissão de rede nem de acesso a sites, não coleta nada e funciona
offline.
**Somente desktop.** O Firefox para Android não implementa a API `menus` — pela
[documentação da Mozilla](https://extensionworkshop.com/documentation/develop/differences-between-desktop-and-android-extensions/),
*"`menus`, which means you cannot add options to context menus"*. Como a
extensão é inteiramente baseada no menu de contexto, ela não tem como funcionar
lá. Por isso o manifesto não declara `gecko_android`.
### Instalar no Firefox
Temporário (some ao fechar o navegador):
1. Abra `about:debugging#/runtime/this-firefox`
2. **Carregar extensão temporária…**
3. Escolha `extension/manifest.json`
Permanente: o Firefox só instala extensões assinadas. Rode `make ext-build` e
envie `web-ext-artifacts/cpfgen-1.0.0.zip` para
[addons.mozilla.org](https://addons.mozilla.org/developers/) — dá para publicar
como *unlisted* e receber um `.xpi` assinado só seu, sem listar na loja.
Para desenvolver, `make ext-run` abre um Firefox já com a extensão carregada e
recarrega a cada alteração.
### Instalar no Brave
1. Abra `brave://extensions`
2. Ligue o **Modo do desenvolvedor** (canto superior direito)
3. **Carregar sem compactação** e escolha a pasta `extension/`
Isso persiste entre reinicializações. O Brave mostra um aviso sobre extensões em
modo desenvolvedor a cada início — é esperado. O mesmo procedimento vale para
Chrome, Chromium e Edge.
### Testes da extensão
```sh
make ext-test # porte JS vs. binários Go + extensão real no Firefox e no Brave
make ext-lint # validação oficial da Mozilla (web-ext lint)
make ext-build # gera o .zip para publicação
```
`make ext-test` roda três coisas:
- `test_generator.mjs` — gera centenas de amostras pelo JS e pelos binários Go e
revalida todos os dígitos verificadores com uma implementação independente;
- `test_extension.py` — sobe um Firefox headless, instala a extensão de verdade,
confere que os 8 itens de menu foram criados e que a inserção funciona em
`input`, `textarea` e `contenteditable`;
- `test_chromium.py` — carrega a extensão no Brave headless e confere que o
service worker MV3 sobe e registra os mesmos 8 itens.
Precisa de `node`, `python3`, `firefox` e `brave` (os dois navegadores e os
binários Go são pulados com aviso se não estiverem presentes).

87
extension/background.js Normal file
View file

@ -0,0 +1,87 @@
import {
generateCPF,
formatCPF,
generateCNPJ,
generateCNPJAlpha,
formatCNPJ,
} from "./generator.js";
// Firefox expõe `browser`, Chromium expõe `chrome`. As APIs usadas aqui são
// compatíveis nos dois, então basta escolher o objeto disponível.
const api = typeof browser !== "undefined" ? browser : chrome;
const MENU_ITEMS = [
{ id: "cpf-formatted", parentId: "cpf", title: "Formatado (000.000.000-00)" },
{ id: "cpf-raw", parentId: "cpf", title: "Só números" },
{
id: "cnpj-formatted",
parentId: "cnpj",
title: "Formatado (00.000.000/0000-00)",
},
{ id: "cnpj-raw", parentId: "cnpj", title: "Só números" },
{ id: "cnpj-alpha-formatted", parentId: "cnpj", title: "Alfanumérico" },
{
id: "cnpj-alpha-raw",
parentId: "cnpj",
title: "Alfanumérico, só números",
},
];
function buildMenus() {
api.contextMenus.removeAll(() => {
// contexts: "editable" faz o menu aparecer apenas em input, textarea e
// elementos contenteditable — nunca no resto da página.
api.contextMenus.create({
id: "cpf",
title: "Gerar CPF",
contexts: ["editable"],
});
api.contextMenus.create({
id: "cnpj",
title: "Gerar CNPJ",
contexts: ["editable"],
});
for (const item of MENU_ITEMS) {
api.contextMenus.create({ ...item, contexts: ["editable"] });
}
});
}
api.runtime.onInstalled.addListener(buildMenus);
api.runtime.onStartup.addListener(buildMenus);
function valueFor(menuItemId) {
switch (menuItemId) {
case "cpf-formatted":
return formatCPF(generateCPF());
case "cpf-raw":
return generateCPF();
case "cnpj-formatted":
return formatCNPJ(generateCNPJ());
case "cnpj-raw":
return generateCNPJ();
case "cnpj-alpha-formatted":
return formatCNPJ(generateCNPJAlpha());
case "cnpj-alpha-raw":
return generateCNPJAlpha();
default:
return null;
}
}
api.contextMenus.onClicked.addListener((info, tab) => {
const value = valueFor(info.menuItemId);
if (value === null || !tab?.id) return;
api.scripting.executeScript({
target: { tabId: tab.id, frameIds: [info.frameId ?? 0] },
files: ["insert.js"],
});
api.scripting.executeScript({
target: { tabId: tab.id, frameIds: [info.frameId ?? 0] },
func: (text) => insertIntoFocused(text),
args: [value],
});
});

104
extension/generator.js Normal file
View file

@ -0,0 +1,104 @@
// Porte direto dos geradores Go:
// cpf_generator/main.go -> generateCPF
// cnpj_generator/cnpj/generate.go -> generateCNPJ / generateCNPJAlpha
//
// Diferença deliberada: aqui o aleatório vem de crypto.getRandomValues em vez
// de math/rand. Os dígitos verificadores seguem exatamente as mesmas contas.
const ALPHA_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const CNPJ_P1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
const CNPJ_P2 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
// Inteiro uniforme em [0, max) sem o viés do módulo.
function randomInt(max) {
const limit = Math.floor(0xffffffff / max) * max;
const buf = new Uint32Array(1);
let v;
do {
crypto.getRandomValues(buf);
v = buf[0];
} while (v >= limit);
return v % max;
}
export function generateCPF() {
const digits = new Array(11);
for (let i = 0; i < 9; i++) {
digits[i] = randomInt(10);
}
let sum = 0;
for (let i = 0; i < 9; i++) {
sum += digits[i] * (10 - i);
}
let rem = sum % 11;
digits[9] = rem < 2 ? 0 : 11 - rem;
sum = 0;
for (let i = 0; i < 10; i++) {
sum += digits[i] * (11 - i);
}
rem = sum % 11;
digits[10] = rem < 2 ? 0 : 11 - rem;
return digits.join("");
}
export function formatCPF(cpf) {
if (cpf.length !== 11) return cpf;
return `${cpf.slice(0, 3)}.${cpf.slice(3, 6)}.${cpf.slice(6, 9)}-${cpf.slice(9)}`;
}
// Peso posicional do caractere: dígitos valem 0-9, letras valem código ASCII - 48
// (A = 17), conforme a regra do CNPJ alfanumérico.
function alphaValue(ch) {
return ch.charCodeAt(0) - 48;
}
function computeDV(s, table) {
let total = 0;
for (let i = 0; i < table.length; i++) {
total += alphaValue(s[i]) * table[i];
}
const r = total % 11;
return r >= 2 ? 11 - r : 0;
}
function isRepetitive(s) {
if (s.length === 0) return false;
for (let i = 1; i < s.length; i++) {
if (s[i] !== s[0]) return false;
}
return true;
}
function generateCNPJBase(alpha) {
for (;;) {
let s = "";
for (let i = 0; i < 12; i++) {
s += alpha
? ALPHA_CHARS[randomInt(ALPHA_CHARS.length)]
: String(randomInt(10));
}
if (isRepetitive(s)) continue;
const dv1 = computeDV(s, CNPJ_P1);
const dv2 = computeDV(s + dv1, CNPJ_P2);
return `${s}${dv1}${dv2}`;
}
}
export function generateCNPJ() {
return generateCNPJBase(false);
}
export function generateCNPJAlpha() {
return generateCNPJBase(true);
}
export function formatCNPJ(cnpj) {
if (cnpj.length !== 14) return cnpj;
return `${cnpj.slice(0, 2)}.${cnpj.slice(2, 5)}.${cnpj.slice(5, 8)}/${cnpj.slice(8, 12)}-${cnpj.slice(12)}`;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
extension/icons/icon-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

7
extension/icons/icon.svg Normal file
View file

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
<rect width="128" height="128" rx="26" fill="#1f6f43"/>
<rect x="24" y="34" width="80" height="14" rx="7" fill="#f2f6f3" opacity="0.55"/>
<rect x="24" y="57" width="80" height="14" rx="7" fill="#f2f6f3" opacity="0.8"/>
<rect x="24" y="80" width="52" height="14" rx="7" fill="#f2f6f3"/>
<circle cx="94" cy="87" r="12" fill="#ffd24a"/>
</svg>

After

Width:  |  Height:  |  Size: 439 B

50
extension/insert.js Normal file
View file

@ -0,0 +1,50 @@
// Injetado na aba via chrome.scripting.executeScript. Roda isolado, sem imports:
// recebe o texto já gerado e o escreve no campo que abriu o menu de contexto.
function insertIntoFocused(text) {
const el = document.activeElement;
if (!el) return false;
const tag = el.tagName;
const isInput = tag === "INPUT" || tag === "TEXTAREA";
if (!isInput && !el.isContentEditable) return false;
el.focus();
// Caminho preferido: execCommand emite os eventos de input nativos, então
// campos controlados por React/Vue/Angular enxergam a mudança. Uma atribuição
// direta em .value seria silenciosamente descartada por esses frameworks.
if (document.execCommand("insertText", false, text)) return true;
// Fallback para quando execCommand é bloqueado ou não suportado.
if (isInput) {
const start = el.selectionStart ?? el.value.length;
const end = el.selectionEnd ?? el.value.length;
// Usa o setter nativo do prototype: React sobrescreve o setter da instância
// para rastrear o estado, e escrever por ele não notificaria o framework.
const proto =
tag === "TEXTAREA"
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, "value").set;
setter.call(el, el.value.slice(0, start) + text + el.value.slice(end));
const pos = start + text.length;
el.setSelectionRange(pos, pos);
} else {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
range.deleteContents();
range.insertNode(document.createTextNode(text));
sel.collapseToEnd();
} else {
el.textContent += text;
}
}
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
return true;
}

25
extension/manifest.json Normal file
View file

@ -0,0 +1,25 @@
{
"manifest_version": 3,
"name": "cpfgen",
"version": "1.0.0",
"description": "Gera CPF e CNPJ válidos direto no menu de contexto de qualquer campo de texto (auxiliar para desenvolvedores).",
"permissions": ["contextMenus", "scripting", "activeTab"],
"background": {
"service_worker": "background.js",
"scripts": ["background.js"],
"type": "module"
},
"icons": {
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"browser_specific_settings": {
"gecko": {
"id": "cpfgen@lucasf.xyz",
"strict_min_version": "140.0",
"data_collection_permissions": {
"required": ["none"]
}
}
}
}

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()

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()

View file

@ -0,0 +1,101 @@
// Valida CPFs/CNPJs vindos do Go e do porte JS usando uma checagem
// independente (recalcula os DVs a partir dos 9/12 primeiros caracteres).
import {
generateCPF, formatCPF,
generateCNPJ, generateCNPJAlpha, formatCNPJ,
} from "./generator.js";
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
function validCPF(s) {
if (!/^\d{11}$/.test(s)) return false;
const d = [...s].map(Number);
let sum = 0;
for (let i = 0; i < 9; i++) sum += d[i] * (10 - i);
let r = sum % 11;
if (d[9] !== (r < 2 ? 0 : 11 - r)) return false;
sum = 0;
for (let i = 0; i < 10; i++) sum += d[i] * (11 - i);
r = sum % 11;
return d[10] === (r < 2 ? 0 : 11 - r);
}
const P1 = [5,4,3,2,9,8,7,6,5,4,3,2];
const P2 = [6,5,4,3,2,9,8,7,6,5,4,3,2];
const val = (c) => c.charCodeAt(0) - 48;
function validCNPJ(s) {
if (!/^[0-9A-Z]{12}\d{2}$/.test(s)) return false;
const dv = (str, t) => {
let tot = 0;
for (let i = 0; i < t.length; i++) tot += val(str[i]) * t[i];
const r = tot % 11;
return r >= 2 ? 11 - r : 0;
};
if (Number(s[12]) !== dv(s, P1)) return false;
return Number(s[13]) === dv(s.slice(0, 13), P2);
}
const here = path.dirname(fileURLToPath(import.meta.url));
const CPF = path.join(here, "..", "bin", "cpfgen");
// cnpjgen vive em outro repo, ao lado deste.
const CNPJ = path.join(here, "..", "..", "cnpj_generator", "bin", "cnpjgen");
const run = (bin, args) => execFileSync(bin, args, { encoding: "utf8" }).trim();
let fail = 0;
const check = (label, ok) => {
if (!ok) { fail++; console.log(`FAIL ${label}`); }
};
const N = 300;
// Referência: saída dos binários Go, quando compilados. Servem de oráculo para
// o porte JS; se não existirem, os testes do JS abaixo rodam do mesmo jeito.
const hasCPF = existsSync(CPF);
const hasCNPJ = existsSync(CNPJ);
if (!hasCPF) console.log("aviso: bin/cpfgen ausente (rode `make build`), pulando");
if (!hasCNPJ) console.log("aviso: cnpj_generator/bin/cnpjgen ausente, pulando");
for (let i = 0; i < N; i++) {
if (hasCPF) check("go cpf", validCPF(run(CPF, ["-num"])));
if (hasCNPJ) {
check("go cnpj", validCNPJ(run(CNPJ, ["-num"])));
check("go cnpj alpha", validCNPJ(run(CNPJ, ["-alpha", "-num"])));
}
}
// Porte JS.
for (let i = 0; i < N; i++) {
check("js cpf", validCPF(generateCPF()));
check("js cnpj", validCNPJ(generateCNPJ()));
check("js cnpj alpha", validCNPJ(generateCNPJAlpha()));
}
// Formatação deve casar com a do Go.
check("fmt cpf", /^\d{3}\.\d{3}\.\d{3}-\d{2}$/.test(formatCPF(generateCPF())));
check("fmt cnpj", /^\d{2}\.\d{3}\.\d{3}\/\d{4}-\d{2}$/.test(formatCNPJ(generateCNPJ())));
check("fmt cnpj alpha",
/^[0-9A-Z]{2}\.[0-9A-Z]{3}\.[0-9A-Z]{3}\/[0-9A-Z]{4}-\d{2}$/
.test(formatCNPJ(generateCNPJAlpha())));
// Formato do Go, lado a lado, para confirmar que o layout é idêntico.
console.log("go cpf :", run(CPF, []));
console.log("js cpf :", formatCPF(generateCPF()));
console.log("go cnpj :", run(CNPJ, []));
console.log("js cnpj :", formatCNPJ(generateCNPJ()));
console.log("go alpha :", run(CNPJ, ["-alpha"]));
console.log("js alpha :", formatCNPJ(generateCNPJAlpha()));
// Distribuição: nenhum dígito verificador deve ficar preso num valor só.
const dvs = new Set();
for (let i = 0; i < 200; i++) dvs.add(generateCPF().slice(9));
console.log(`\nDVs de CPF distintos em 200: ${dvs.size}`);
if (fail === 0) {
console.log("\nOK: geradores JS conferem com os binários Go");
} else {
console.log(`\n${fail} falhas`);
process.exit(1);
}

2
extension/testdata.html Normal file
View file

@ -0,0 +1,2 @@
<!doctype html><meta charset=utf-8><title>t</title>
<input id=a><textarea id=b></textarea><div id=c contenteditable>x</div>