diff --git a/.gitignore b/.gitignore index e4f39af..b2d601b 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ go.work.sum # Editor/IDE # .idea/ # .vscode/ + +# extensão de navegador +web-ext-artifacts/ diff --git a/Makefile b/Makefile index e8f66c6..8265e81 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,31 @@ -.PHONY: build test clean +.PHONY: build test clean ext-test ext-lint ext-build ext-run build: - @mkdir -p bin && go build -o bin/cpfgen . + @mkdir -p bin && go build -ldflags="-s -w" -o bin/cpfgen . test: @go test -v ./... 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 diff --git a/README.md b/README.md index 4c26d1b..8e0f5a6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # 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 @@ -20,3 +21,75 @@ make test ./bin/cpfgen # 487.197.991-10 ./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). diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..74c65d2 --- /dev/null +++ b/extension/background.js @@ -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], + }); +}); diff --git a/extension/generator.js b/extension/generator.js new file mode 100644 index 0000000..5f32e06 --- /dev/null +++ b/extension/generator.js @@ -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)}`; +} diff --git a/extension/icons/icon-128.png b/extension/icons/icon-128.png new file mode 100644 index 0000000..80f8604 Binary files /dev/null and b/extension/icons/icon-128.png differ diff --git a/extension/icons/icon-48.png b/extension/icons/icon-48.png new file mode 100644 index 0000000..79d2c45 Binary files /dev/null and b/extension/icons/icon-48.png differ diff --git a/extension/icons/icon.svg b/extension/icons/icon.svg new file mode 100644 index 0000000..eb5bba2 --- /dev/null +++ b/extension/icons/icon.svg @@ -0,0 +1,7 @@ + diff --git a/extension/insert.js b/extension/insert.js new file mode 100644 index 0000000..c90e7f1 --- /dev/null +++ b/extension/insert.js @@ -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; +} diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..5215b1a --- /dev/null +++ b/extension/manifest.json @@ -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"] + } + } + } +} diff --git a/extension/test_chromium.py b/extension/test_chromium.py new file mode 100644 index 0000000..3cf271e --- /dev/null +++ b/extension/test_chromium.py @@ -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() diff --git a/extension/test_extension.py b/extension/test_extension.py new file mode 100644 index 0000000..9087f86 --- /dev/null +++ b/extension/test_extension.py @@ -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() diff --git a/extension/test_generator.mjs b/extension/test_generator.mjs new file mode 100644 index 0000000..6d3fadd --- /dev/null +++ b/extension/test_generator.mjs @@ -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); +} diff --git a/extension/testdata.html b/extension/testdata.html new file mode 100644 index 0000000..22e3e2f --- /dev/null +++ b/extension/testdata.html @@ -0,0 +1,2 @@ +