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
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -39,3 +39,6 @@ go.work.sum
|
||||||
# Editor/IDE
|
# Editor/IDE
|
||||||
# .idea/
|
# .idea/
|
||||||
# .vscode/
|
# .vscode/
|
||||||
|
|
||||||
|
# extensão de navegador
|
||||||
|
web-ext-artifacts/
|
||||||
|
|
|
||||||
27
Makefile
27
Makefile
|
|
@ -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
|
||||||
|
|
|
||||||
75
README.md
75
README.md
|
|
@ -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
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