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:
parent
688c5a48f3
commit
7632cfde2c
14 changed files with 792 additions and 4 deletions
87
extension/background.js
Normal file
87
extension/background.js
Normal 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
104
extension/generator.js
Normal 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)}`;
|
||||
}
|
||||
BIN
extension/icons/icon-128.png
Normal file
BIN
extension/icons/icon-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
BIN
extension/icons/icon-48.png
Normal file
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
7
extension/icons/icon.svg
Normal 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
50
extension/insert.js
Normal 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
25
extension/manifest.json
Normal 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
174
extension/test_chromium.py
Normal 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
141
extension/test_extension.py
Normal 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()
|
||||
101
extension/test_generator.mjs
Normal file
101
extension/test_generator.mjs
Normal 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
2
extension/testdata.html
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue