diff --git a/.agents/add-validator-rule.md b/.agents/add-validator-rule.md new file mode 100644 index 0000000..8957d2d --- /dev/null +++ b/.agents/add-validator-rule.md @@ -0,0 +1,117 @@ +# Skill: add-validator-rule + +Cria uma nova rule de validação no `payment-template-validator`. + +## Uso + +``` +/add-validator-rule +``` + +Exemplo: +``` +/add-validator-rule requiredFiles — garante que payment.html, i18n/pt-BR.json e icon estão presentes +``` + +--- + +## O que o skill faz + +1. Cria `packages/validator/src/rules/.js` com a assinatura padrão +2. Registra a rule em `packages/validator/src/rules/index.js` +3. Cria `packages/validator/src/tests/.test.js` com testes para o happy path e pelo menos um caso de erro +4. Roda `npm test` para confirmar que tudo passa +5. Mostra o output da CLI contra `src/` para confirmar que o template default continua passando + +--- + +## Contrato de uma rule + +Toda rule é um arquivo em `packages/validator/src/rules/` que exporta uma função com esta assinatura: + +```js +function nomeDaRule(template, opts = {}) { + // template: { html, css, i18n, i18nFiles, icon, assets, templateDir } + // opts: ValidateOptions passado pelo chamador + // retorna: ValidationError[] — vazio se passar, com erros se falhar +} + +// Nome da rule (usado no registry e nas mensagens de erro) +nomeDaRule.ruleName = 'nomeDaRule'; + +// Contexto exibido na CLI ao lado do ✓/✗ (opcional mas recomendado) +nomeDaRule.describe = function(template) { + return 'descrição curta do que foi verificado'; +}; + +module.exports = nomeDaRule; +``` + +## Tipo ValidationError + +```js +{ + rule: string, // nome da rule (igual a ruleName) + message: string, // mensagem em pt-BR, clara o suficiente para o parceiro corrigir + file?: string, // path do arquivo que causou o erro (quando aplicável) + severity?: 'error' | 'warning', // default: 'error' +} +``` + +## Objeto template + +| Campo | Tipo | O que contém | +|---|---|---| +| `html` | `FileSlot\|null` | `partials/payment.html` ou `payment.html` | +| `css` | `FileSlot\|null` | `assets/css/less/style.css` ou `style.css` | +| `i18n` | `FileSlot\|null` | `i18n/pt-BR.json` (slot principal) | +| `i18nFiles` | `FileSlot[]` | todos os `i18n/*.json` ordenados | +| `icon` | `FileSlot\|null` | `assets/img/icon.png` | +| `assets` | `FileRef[]` | demais arquivos não-slot | +| `templateDir` | `string` | path absoluto da raiz do template | + +`FileSlot` tem `.path`, `.size`, `.content` (string utf-8, lazy) e `.buffer` (Buffer, lazy). +`FileRef` tem só `.path` e `.size`. + +## Registrar no index + +Após criar o arquivo da rule, adicione no `packages/validator/src/rules/index.js`: + +```js +module.exports = [ + require('./maxFileSize'), + require('./i18nKeyConsistency'), + require('./htmlSafety'), + require('./noExternalRefs'), + require('./'), // ← adicionar aqui +]; +``` + +## Template de teste + +```js +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const nomeDaRule = require('../rules/nomeDaRule'); + +test('nomeDaRule — passa quando [condição válida]', () => { + const template = { /* montar o template mínimo necessário */ }; + assert.equal(nomeDaRule(template).length, 0); +}); + +test('nomeDaRule — falha quando [condição de erro]', () => { + const template = { /* montar template com o problema */ }; + const errors = nomeDaRule(template); + assert.equal(errors.length, 1); + assert.equal(errors[0].rule, 'nomeDaRule'); + assert.match(errors[0].message, /texto esperado/); +}); +``` + +## Checklist de entrega + +- [ ] Arquivo da rule criado com `ruleName` e `describe` definidos +- [ ] Rule registrada em `rules/index.js` +- [ ] Testes cobrindo happy path + pelo menos 1 erro + edge case (template sem o campo relevante) +- [ ] `npm test` passando (37+ testes) +- [ ] `node validator/cli.js src/` retorna exit 0 (template default não quebra com a nova rule) diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000..7bcb955 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents \ No newline at end of file diff --git a/examples/bad-templates/external-refs/assets/css/less/style.css b/examples/bad-templates/external-refs/assets/css/less/style.css new file mode 100644 index 0000000..6f2a0b8 --- /dev/null +++ b/examples/bad-templates/external-refs/assets/css/less/style.css @@ -0,0 +1,2 @@ +@import "https://fonts.googleapis.com/css2?family=Roboto"; +.payment-title { background: url("https://cdn.partner.com/bg.png") no-repeat; } diff --git a/examples/bad-templates/external-refs/partials/payment.html b/examples/bad-templates/external-refs/partials/payment.html new file mode 100644 index 0000000..f70a7c0 --- /dev/null +++ b/examples/bad-templates/external-refs/partials/payment.html @@ -0,0 +1,5 @@ +
+ logo + +

Pagar

+
diff --git a/examples/bad-templates/html-unsafe/partials/payment.html b/examples/bad-templates/html-unsafe/partials/payment.html new file mode 100644 index 0000000..0a9f3a6 --- /dev/null +++ b/examples/bad-templates/html-unsafe/partials/payment.html @@ -0,0 +1,5 @@ +
+ +

Pagar

+ +
diff --git a/examples/bad-templates/i18n-inconsistente/i18n/en-US.json b/examples/bad-templates/i18n-inconsistente/i18n/en-US.json new file mode 100644 index 0000000..ac6ef6a --- /dev/null +++ b/examples/bad-templates/i18n-inconsistente/i18n/en-US.json @@ -0,0 +1 @@ +{"payment.title":"Payment","payment.cta":"Pay now"} diff --git a/examples/bad-templates/i18n-inconsistente/i18n/es.json b/examples/bad-templates/i18n-inconsistente/i18n/es.json new file mode 100644 index 0000000..066d522 --- /dev/null +++ b/examples/bad-templates/i18n-inconsistente/i18n/es.json @@ -0,0 +1 @@ +{"payment.title":"Pago","payment.cta":"Pagar ahora","payment.extra":"Algo extra"} diff --git a/examples/bad-templates/i18n-inconsistente/i18n/pt-BR.json b/examples/bad-templates/i18n-inconsistente/i18n/pt-BR.json new file mode 100644 index 0000000..49d3b8b --- /dev/null +++ b/examples/bad-templates/i18n-inconsistente/i18n/pt-BR.json @@ -0,0 +1 @@ +{"payment.title":"Pagamento","payment.cta":"Pagar agora","payment.help":"Ajuda"} diff --git a/examples/bad-templates/i18n-inconsistente/partials/payment.html b/examples/bad-templates/i18n-inconsistente/partials/payment.html new file mode 100644 index 0000000..933e79d --- /dev/null +++ b/examples/bad-templates/i18n-inconsistente/partials/payment.html @@ -0,0 +1 @@ +

Pagar

diff --git a/examples/bad-templates/max-file-size/logo.bin b/examples/bad-templates/max-file-size/logo.bin new file mode 100644 index 0000000..0069b5c Binary files /dev/null and b/examples/bad-templates/max-file-size/logo.bin differ diff --git a/examples/bad-templates/max-file-size/partials/payment.html b/examples/bad-templates/max-file-size/partials/payment.html new file mode 100644 index 0000000..933e79d --- /dev/null +++ b/examples/bad-templates/max-file-size/partials/payment.html @@ -0,0 +1 @@ +

Pagar

diff --git a/examples/bad-templates/tudo-errado/assets/css/less/style.css b/examples/bad-templates/tudo-errado/assets/css/less/style.css new file mode 100644 index 0000000..a789f7c --- /dev/null +++ b/examples/bad-templates/tudo-errado/assets/css/less/style.css @@ -0,0 +1 @@ +.payment { background: url("https://cdn.evil.com/track.png"); } diff --git a/examples/bad-templates/tudo-errado/i18n/en-US.json b/examples/bad-templates/tudo-errado/i18n/en-US.json new file mode 100644 index 0000000..ce8d34a --- /dev/null +++ b/examples/bad-templates/tudo-errado/i18n/en-US.json @@ -0,0 +1 @@ +{"payment.title":"Payment"} diff --git a/examples/bad-templates/tudo-errado/i18n/pt-BR.json b/examples/bad-templates/tudo-errado/i18n/pt-BR.json new file mode 100644 index 0000000..34e17fa --- /dev/null +++ b/examples/bad-templates/tudo-errado/i18n/pt-BR.json @@ -0,0 +1 @@ +{"payment.title":"Pagamento","payment.cta":"Pagar"} diff --git a/examples/bad-templates/tudo-errado/partials/payment.html b/examples/bad-templates/tudo-errado/partials/payment.html new file mode 100644 index 0000000..0a9f3a6 --- /dev/null +++ b/examples/bad-templates/tudo-errado/partials/payment.html @@ -0,0 +1,5 @@ +
+ +

Pagar

+ +
diff --git a/examples/bad-templates/tudo-errado/payload.bin b/examples/bad-templates/tudo-errado/payload.bin new file mode 100644 index 0000000..fb9f15d Binary files /dev/null and b/examples/bad-templates/tudo-errado/payload.bin differ diff --git a/examples/good-example/assets/css/less/style.css b/examples/good-example/assets/css/less/style.css new file mode 100644 index 0000000..42518cd --- /dev/null +++ b/examples/good-example/assets/css/less/style.css @@ -0,0 +1,53 @@ +[data-payment-template] .payment-title { + font-size: 16px; + font-weight: bold; + margin: 0 0 12px; + color: #333; +} + +[data-payment-template] .payment-description { + margin-bottom: 16px; +} + +[data-payment-template] .payment-description-text { + font-size: 14px; + color: #666; + line-height: 1.5; +} + +[data-payment-template] .payment-benefits { + list-style: none; + padding: 0; + margin: 0 0 16px; + display: flex; + gap: 12px; +} + +[data-payment-template] .payment-benefit { + flex: 1; + padding: 10px; + background: #f9f9f9; + border-radius: 4px; +} + +[data-payment-template] .payment-benefit-title { + font-size: 13px; + font-weight: bold; + margin: 0 0 4px; + color: #333; +} + +[data-payment-template] .payment-benefit-desc { + font-size: 12px; + color: #666; + margin: 0; +} + +[data-payment-template] .payment-logo { + text-align: center; + margin-top: 12px; +} + +[data-payment-template] .payment-logo img { + max-height: 48px; +} diff --git a/examples/good-example/assets/img/icon.png b/examples/good-example/assets/img/icon.png new file mode 100644 index 0000000..041b41d Binary files /dev/null and b/examples/good-example/assets/img/icon.png differ diff --git a/examples/good-example/i18n/en-US.json b/examples/good-example/i18n/en-US.json new file mode 100644 index 0000000..b4c1dc8 --- /dev/null +++ b/examples/good-example/i18n/en-US.json @@ -0,0 +1,8 @@ +{ + "payment.title": "Example Payment", + "payment.instructions": "After checkout, you will be redirected to complete your payment.", + "payment.benefit.secure": "Secure", + "payment.benefit.secureDesc": "Certified and protected environment.", + "payment.benefit.fast": "Fast", + "payment.benefit.fastDesc": "Approval in seconds." +} diff --git a/examples/good-example/i18n/es.json b/examples/good-example/i18n/es.json new file mode 100644 index 0000000..ea258a5 --- /dev/null +++ b/examples/good-example/i18n/es.json @@ -0,0 +1,8 @@ +{ + "payment.title": "Pago Ejemplo", + "payment.instructions": "Al finalizar la compra, será redirigido para completar el pago.", + "payment.benefit.secure": "Seguro", + "payment.benefit.secureDesc": "Entorno certificado y protegido.", + "payment.benefit.fast": "Rápido", + "payment.benefit.fastDesc": "Aprobación en segundos." +} diff --git a/examples/good-example/i18n/fr.json b/examples/good-example/i18n/fr.json new file mode 100644 index 0000000..d477ab9 --- /dev/null +++ b/examples/good-example/i18n/fr.json @@ -0,0 +1,8 @@ +{ + "payment.title": "Paiement Exemple", + "payment.instructions": "Après la commande, vous serez redirigé pour finaliser le paiement.", + "payment.benefit.secure": "Sécurisé", + "payment.benefit.secureDesc": "Environnement certifié et protégé.", + "payment.benefit.fast": "Rapide", + "payment.benefit.fastDesc": "Approbation en quelques secondes." +} diff --git a/examples/good-example/i18n/pt-BR.json b/examples/good-example/i18n/pt-BR.json new file mode 100644 index 0000000..d431125 --- /dev/null +++ b/examples/good-example/i18n/pt-BR.json @@ -0,0 +1,8 @@ +{ + "payment.title": "Pagamento Exemplo", + "payment.instructions": "Ao finalizar a compra, você será redirecionado para concluir o pagamento.", + "payment.benefit.secure": "Seguro", + "payment.benefit.secureDesc": "Ambiente certificado e protegido.", + "payment.benefit.fast": "Rápido", + "payment.benefit.fastDesc": "Aprovação em segundos." +} diff --git a/examples/good-example/partials/payment.html b/examples/good-example/partials/payment.html new file mode 100644 index 0000000..4b04b36 --- /dev/null +++ b/examples/good-example/partials/payment.html @@ -0,0 +1,24 @@ +
+

Pagamento Exemplo

+ +
+

+ Ao finalizar a compra, você será redirecionado para concluir o pagamento. +

+
+ +
    +
  • +

    Seguro

    +

    Ambiente certificado e protegido.

    +
  • +
  • +

    Rápido

    +

    Aprovação em segundos.

    +
  • +
+ + +
diff --git a/package.json b/package.json index c98e980..e427a1b 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,10 @@ "type": "git", "url": "http://github.com/augustocb/pink" }, + "scripts": { + "validate": "node validator/cli.js", + "test": "node --test packages/validator/src/tests" + }, "devDependencies": { "connect-livereload": "^0.5.4", "grunt": "^1.0.1", diff --git a/packages/validator/package.json b/packages/validator/package.json new file mode 100644 index 0000000..6712840 --- /dev/null +++ b/packages/validator/package.json @@ -0,0 +1,6 @@ +{ + "name": "@vtex/payment-template-validator", + "version": "0.1.0", + "main": "src/index.js", + "license": "MIT" +} diff --git a/packages/validator/src/index.js b/packages/validator/src/index.js new file mode 100644 index 0000000..cfb5d4b --- /dev/null +++ b/packages/validator/src/index.js @@ -0,0 +1,10 @@ +const { run } = require('./runner'); +const rules = require('./rules'); + +async function validate(templateDir, opts) { + const { errors, details } = run(templateDir, rules, opts || {}); + const ok = errors.every(e => e.severity === 'warning'); + return { ok, errors, details }; +} + +module.exports = { validate, rules }; diff --git a/packages/validator/src/rules/assetsReferenced.js b/packages/validator/src/rules/assetsReferenced.js new file mode 100644 index 0000000..de83383 --- /dev/null +++ b/packages/validator/src/rules/assetsReferenced.js @@ -0,0 +1,87 @@ +const path = require('path'); + +const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico']); + +function isExternalOrData(ref) { + return /^https?:\/\//i.test(ref) || /^data:/i.test(ref); +} + +function collectRefs(content, pattern) { + const refs = new Set(); + let match; + const re = new RegExp(pattern, 'gi'); + while ((match = re.exec(content)) !== null) { + const ref = match[1]; + if (!isExternalOrData(ref) && !ref.startsWith('/')) { + refs.add(ref); + } + } + return refs; +} + +function assetsReferenced(template) { + const refs = new Set(); + + if (template.html) { + for (const r of collectRefs(template.html.content, /(?:src|href)\s*=\s*["']([^"'#?]+)/)) refs.add(r); + } + if (template.css) { + for (const r of collectRefs(template.css.content, /url\s*\(\s*["']?([^"')]+)/)) refs.add(r); + } + + // Resolve refs to absolute paths relative to templateDir + const resolveRef = (ref) => { + if (template.html && !ref.startsWith('./') && !ref.startsWith('../')) { + return path.resolve(template.templateDir, ref); + } + return path.resolve(template.templateDir, ref); + }; + + // Include slot files (icon, html, css, i18n) as valid reference targets + const slotPaths = [template.html, template.css, template.i18n, template.icon] + .filter(Boolean).map(s => s.path); + const assetPaths = new Set([...(template.assets || []).map(a => a.path), ...slotPaths]); + const imageAssets = (template.assets || []).filter(a => IMAGE_EXTS.has(path.extname(a.path).toLowerCase())); + const imageAssetPaths = new Set(imageAssets.map(a => a.path)); + + const errors = []; + + // Broken references: ref in HTML/CSS → file not in assets + for (const ref of refs) { + if (path.extname(ref) === '' || !IMAGE_EXTS.has(path.extname(ref).toLowerCase())) continue; + const abs = resolveRef(ref); + if (!assetPaths.has(abs)) { + errors.push({ + rule: 'assetsReferenced', + message: `Referência quebrada: "${ref}" não encontrado nos assets (vai dar 404 em produção)`, + severity: 'error', + }); + } + } + + // Orphan assets: image file present but never referenced + const referencedAbs = new Set([...refs].map(resolveRef)); + for (const asset of imageAssets) { + const name = path.basename(asset.path); + // Check by basename too (HTML often references just the filename) + const isReferenced = referencedAbs.has(asset.path) || + [...refs].some(r => path.basename(r) === name || r === name); + if (!isReferenced) { + errors.push({ + rule: 'assetsReferenced', + message: `Asset órfão: "${name}" presente mas nunca referenciado em HTML ou CSS`, + file: asset.path, + severity: 'warning', + }); + } + } + + return errors; +} + +assetsReferenced.ruleName = 'assetsReferenced'; +assetsReferenced.describe = function(template) { + const imgs = (template.assets || []).filter(a => IMAGE_EXTS.has(path.extname(a.path).toLowerCase())).length; + return `${imgs} imagem(ns) nos assets`; +}; +module.exports = assetsReferenced; diff --git a/packages/validator/src/rules/cssScope.js b/packages/validator/src/rules/cssScope.js new file mode 100644 index 0000000..85cc968 --- /dev/null +++ b/packages/validator/src/rules/cssScope.js @@ -0,0 +1,131 @@ +// Exempted at-rule prefixes that don't have CSS selectors as their block head +const EXEMPT_AT_RULES = /^@(keyframes|font-face|charset|namespace)/i; + +function extractSelectors(css) { + // Remove comments + const stripped = css.replace(/\/\*[\s\S]*?\*\//g, ''); + const selectors = []; + // Match selector blocks: capture everything before { up to the previous } + // We walk char-by-char tracking depth + let depth = 0; + let blockStart = 0; + let selectorBuffer = ''; + + for (let i = 0; i < stripped.length; i++) { + const ch = stripped[i]; + if (ch === '{') { + if (depth === 0) { + selectorBuffer = stripped.slice(blockStart, i).trim(); + } + depth++; + } else if (ch === '}') { + depth--; + if (depth === 0) { + if (selectorBuffer) { + selectors.push(selectorBuffer); + } + selectorBuffer = ''; + blockStart = i + 1; + } + } + } + return selectors; +} + +function cssScope(template) { + if (!template.css) return []; + + const selectors = extractSelectors(template.css.content); + const errors = []; + + for (const sel of selectors) { + // Skip exempt at-rules like @keyframes, @font-face + if (EXEMPT_AT_RULES.test(sel)) continue; + + // @media / @supports: extract inner selectors by recursing on the block content + // At depth 0, @media "selector" is the at-rule condition, not a CSS selector + if (/^@(media|supports|layer)/i.test(sel)) continue; + + // Split multiple selectors (comma-separated) + const parts = sel.split(',').map(s => s.trim()).filter(Boolean); + for (const part of parts) { + if (!part.includes('[data-payment-template]')) { + errors.push({ + rule: 'cssScope', + message: `Seletor CSS fora do escopo [data-payment-template]: "${part.slice(0, 60)}"`, + file: template.css.path, + severity: 'warning', + }); + } + } + } + + return errors; +} + +// Recurse into @media blocks to check inner selectors +function cssScopeDeep(template) { + if (!template.css) return []; + return cssScopeWithContent(template.css.content, template.css.path); +} + +function cssScopeWithContent(css, filePath) { + const stripped = css.replace(/\/\*[\s\S]*?\*\//g, ''); + const errors = []; + let depth = 0; + let blockStart = 0; + let selectorBuf = ''; + let innerContent = ''; + let innerStart = 0; + + for (let i = 0; i < stripped.length; i++) { + const ch = stripped[i]; + if (ch === '{') { + if (depth === 0) { + selectorBuf = stripped.slice(blockStart, i).trim(); + innerStart = i + 1; + } + depth++; + } else if (ch === '}') { + depth--; + if (depth === 0) { + innerContent = stripped.slice(innerStart, i); + + if (!EXEMPT_AT_RULES.test(selectorBuf)) { + if (/^@(media|supports|layer)/i.test(selectorBuf)) { + // Recurse into @media block + errors.push(...cssScopeWithContent(innerContent, filePath)); + } else { + // Regular selector + const parts = selectorBuf.split(',').map(s => s.trim()).filter(Boolean); + for (const part of parts) { + if (!part.includes('[data-payment-template]')) { + errors.push({ + rule: 'cssScope', + message: `Seletor CSS fora do escopo [data-payment-template]: "${part.slice(0, 60)}"`, + file: filePath, + severity: 'warning', + }); + } + } + } + } + + selectorBuf = ''; + blockStart = i + 1; + } + } + } + return errors; +} + +function cssScopeRule(template) { + if (!template.css) return []; + return cssScopeWithContent(template.css.content, template.css.path); +} + +cssScopeRule.ruleName = 'cssScope'; +cssScopeRule.describe = function(template) { + return template.css ? 'CSS' : 'sem CSS'; +}; +module.exports = cssScopeRule; diff --git a/packages/validator/src/rules/htmlSafety.js b/packages/validator/src/rules/htmlSafety.js new file mode 100644 index 0000000..7213187 --- /dev/null +++ b/packages/validator/src/rules/htmlSafety.js @@ -0,0 +1,40 @@ +function htmlSafety(template) { + if (!template.html) return []; + + const content = template.html.content; + const errors = []; + + if (/ não é permitida no template', + file: template.html.path, + }); + } + + if (/\beval\s*\(/.test(content)) { + errors.push({ + rule: 'htmlSafety', + message: 'Uso de eval() não é permitido no template', + file: template.html.path, + }); + } + + const inlineHandlerMatch = content.match(/\bon\w+\s*=/i); + if (inlineHandlerMatch) { + errors.push({ + rule: 'htmlSafety', + message: `Atributo de evento inline não é permitido: ${inlineHandlerMatch[0].trim()}`, + file: template.html.path, + }); + } + + return errors; +} + +htmlSafety.ruleName = 'htmlSafety'; +htmlSafety.describe = function(template) { + return template.html ? path.basename(template.html.path) : 'sem HTML'; +}; +const path = require('path'); +module.exports = htmlSafety; diff --git a/packages/validator/src/rules/i18nKeyConsistency.js b/packages/validator/src/rules/i18nKeyConsistency.js new file mode 100644 index 0000000..0fbdca1 --- /dev/null +++ b/packages/validator/src/rules/i18nKeyConsistency.js @@ -0,0 +1,65 @@ +function i18nKeyConsistency(template) { + const files = template.i18nFiles; + if (!files || files.length < 2) return []; + + const base = files[0]; + let baseKeys; + try { + baseKeys = new Set(Object.keys(JSON.parse(base.content))); + } catch { + return [{ + rule: 'i18nKeyConsistency', + message: `JSON inválido em ${base.name}`, + file: base.path, + }]; + } + + const errors = []; + for (const file of files.slice(1)) { + let fileKeys; + try { + fileKeys = new Set(Object.keys(JSON.parse(file.content))); + } catch { + errors.push({ + rule: 'i18nKeyConsistency', + message: `JSON inválido em ${file.name}`, + file: file.path, + }); + continue; + } + + for (const key of baseKeys) { + if (!fileKeys.has(key)) { + errors.push({ + rule: 'i18nKeyConsistency', + message: `${file.name} não tem a chave '${key}' que existe em ${base.name}`, + file: file.path, + }); + } + } + for (const key of fileKeys) { + if (!baseKeys.has(key)) { + errors.push({ + rule: 'i18nKeyConsistency', + message: `${file.name} tem a chave extra '${key}' que não existe em ${base.name}`, + file: file.path, + }); + } + } + } + + return errors; +} + +i18nKeyConsistency.ruleName = 'i18nKeyConsistency'; +i18nKeyConsistency.describe = function(template) { + const files = template.i18nFiles || []; + if (files.length === 0) return 'nenhum arquivo i18n encontrado'; + try { + const keys = Object.keys(JSON.parse(files[0].content)).length; + return `${files.length} locale(s), ${keys} chave(s)`; + } catch { + return `${files.length} locale(s)`; + } +}; +module.exports = i18nKeyConsistency; diff --git a/packages/validator/src/rules/iconDimensions.js b/packages/validator/src/rules/iconDimensions.js new file mode 100644 index 0000000..734636c --- /dev/null +++ b/packages/validator/src/rules/iconDimensions.js @@ -0,0 +1,47 @@ +const DEFAULT_MIN = 40; +const DEFAULT_MAX = 200; + +function readPngDimensions(buffer) { + if (buffer.length < 24) return null; + // Check PNG signature + if (buffer.toString('ascii', 12, 16) !== 'IHDR') return null; + return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; +} + +function iconDimensions(template, opts = {}) { + if (!template.icon) return []; + + const min = (opts && opts.iconMinDimension) || DEFAULT_MIN; + const max = (opts && opts.iconMaxDimension) || DEFAULT_MAX; + + const dims = readPngDimensions(template.icon.buffer); + if (!dims) return []; + + const { width, height } = dims; + const largest = Math.max(width, height); + const smallest = Math.min(width, height); + + if (largest > max) { + return [{ + rule: 'iconDimensions', + message: `Ícone ${width}×${height}px excede o máximo permitido de ${max}px — estoura o layout do checkout`, + file: template.icon.path, + }]; + } + if (smallest < min) { + return [{ + rule: 'iconDimensions', + message: `Ícone ${width}×${height}px abaixo do mínimo de ${min}px — muito pequeno para exibição`, + file: template.icon.path, + }]; + } + return []; +} + +iconDimensions.ruleName = 'iconDimensions'; +iconDimensions.describe = function(template) { + if (!template.icon) return 'sem ícone'; + const dims = readPngDimensions(template.icon.buffer); + return dims ? `${dims.width}×${dims.height}px` : 'PNG ilegível'; +}; +module.exports = iconDimensions; diff --git a/packages/validator/src/rules/index.js b/packages/validator/src/rules/index.js new file mode 100644 index 0000000..020d521 --- /dev/null +++ b/packages/validator/src/rules/index.js @@ -0,0 +1,13 @@ +module.exports = [ + require('./maxFileSize'), + require('./i18nKeyConsistency'), + require('./htmlSafety'), + require('./noExternalRefs'), + require('./requiredFiles'), + require('./noInlineStyles'), + require('./noRelativeBacktrack'), + require('./iconDimensions'), + require('./maxImageDimensions'), + require('./cssScope'), + require('./assetsReferenced'), +]; diff --git a/packages/validator/src/rules/maxFileSize.js b/packages/validator/src/rules/maxFileSize.js new file mode 100644 index 0000000..9dd0ea8 --- /dev/null +++ b/packages/validator/src/rules/maxFileSize.js @@ -0,0 +1,25 @@ +const DEFAULT_MAX_BYTES = 128 * 1024; // 128KB + +function maxFileSize(template, opts = {}) { + const limit = (opts && opts.maxFileSize) || DEFAULT_MAX_BYTES; + const total = (template.assets || []) + .concat([template.html, template.css, template.i18n, template.icon].filter(Boolean)) + .reduce((sum, file) => sum + (file.size || 0), 0); + + if (total > limit) { + return [{ + rule: 'maxFileSize', + message: `Tamanho total ${total} bytes excede o limite de ${limit} bytes (${Math.round(limit / 1024)}KB)`, + }]; + } + return []; +} + +maxFileSize.ruleName = 'maxFileSize'; +maxFileSize.describe = function(template) { + const files = (template.assets || []) + .concat([template.html, template.css, template.i18n, template.icon].filter(Boolean)); + const total = files.reduce((sum, f) => sum + (f.size || 0), 0); + return `${files.length} arquivo(s), ${Math.round(total / 1024)} KB`; +}; +module.exports = maxFileSize; diff --git a/packages/validator/src/rules/maxImageDimensions.js b/packages/validator/src/rules/maxImageDimensions.js new file mode 100644 index 0000000..153d245 --- /dev/null +++ b/packages/validator/src/rules/maxImageDimensions.js @@ -0,0 +1,40 @@ +const DEFAULT_MAX = 2000; + +function readPngDimensions(buffer) { + if (buffer.length < 24) return null; + if (buffer.toString('ascii', 12, 16) !== 'IHDR') return null; + return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; +} + +function maxImageDimensions(template, opts = {}) { + const max = (opts && opts.maxImageDimension) || DEFAULT_MAX; + const errors = []; + + for (const asset of (template.assets || [])) { + if (!asset.path.toLowerCase().endsWith('.png')) continue; + let buf; + try { + buf = require('fs').readFileSync(asset.path); + } catch { + continue; + } + const dims = readPngDimensions(buf); + if (!dims) continue; + const { width, height } = dims; + if (width > max || height > max) { + errors.push({ + rule: 'maxImageDimensions', + message: `${require('path').basename(asset.path)}: ${width}×${height}px excede o máximo de ${max}px`, + file: asset.path, + }); + } + } + return errors; +} + +maxImageDimensions.ruleName = 'maxImageDimensions'; +maxImageDimensions.describe = function(template) { + const pngs = (template.assets || []).filter(a => a.path.toLowerCase().endsWith('.png')).length; + return `${pngs} imagem(ns) PNG`; +}; +module.exports = maxImageDimensions; diff --git a/packages/validator/src/rules/noExternalRefs.js b/packages/validator/src/rules/noExternalRefs.js new file mode 100644 index 0000000..014260a --- /dev/null +++ b/packages/validator/src/rules/noExternalRefs.js @@ -0,0 +1,50 @@ +function noExternalRefs(template) { + const errors = []; + + if (template.html) { + const content = template.html.content; + const attrPattern = /(?:src|href)\s*=\s*["']?(https?:\/\/[^"'\s>]+)/gi; + let match; + while ((match = attrPattern.exec(content)) !== null) { + errors.push({ + rule: 'noExternalRefs', + message: `URL externa bloqueada em HTML: ${match[1]}`, + file: template.html.path, + }); + } + } + + if (template.css) { + const content = template.css.content; + + const urlPattern = /url\s*\(\s*["']?(https?:\/\/[^"'\s)]+)/gi; + let match; + while ((match = urlPattern.exec(content)) !== null) { + errors.push({ + rule: 'noExternalRefs', + message: `URL externa bloqueada em CSS: ${match[1]}`, + file: template.css.path, + }); + } + + const importPattern = /@import\s+["'](https?:\/\/[^"']+)/gi; + while ((match = importPattern.exec(content)) !== null) { + errors.push({ + rule: 'noExternalRefs', + message: `@import externo bloqueado em CSS: ${match[1]}`, + file: template.css.path, + }); + } + } + + return errors; +} + +noExternalRefs.ruleName = 'noExternalRefs'; +noExternalRefs.describe = function(template) { + const parts = []; + if (template.html) parts.push('HTML'); + if (template.css) parts.push('CSS'); + return parts.length ? parts.join(' + ') : 'sem HTML/CSS'; +}; +module.exports = noExternalRefs; diff --git a/packages/validator/src/rules/noInlineStyles.js b/packages/validator/src/rules/noInlineStyles.js new file mode 100644 index 0000000..1852ee0 --- /dev/null +++ b/packages/validator/src/rules/noInlineStyles.js @@ -0,0 +1,16 @@ +function noInlineStyles(template) { + if (!template.html) return []; + if (!/\bstyle\s*=/i.test(template.html.content)) return []; + return [{ + rule: 'noInlineStyles', + message: 'Atributo style= inline não é permitido — use classes CSS no arquivo de estilos', + file: template.html.path, + }]; +} + +noInlineStyles.ruleName = 'noInlineStyles'; +noInlineStyles.describe = function(template) { + return template.html ? path.basename(template.html.path) : 'sem HTML'; +}; +const path = require('path'); +module.exports = noInlineStyles; diff --git a/packages/validator/src/rules/noRelativeBacktrack.js b/packages/validator/src/rules/noRelativeBacktrack.js new file mode 100644 index 0000000..f8d6f9d --- /dev/null +++ b/packages/validator/src/rules/noRelativeBacktrack.js @@ -0,0 +1,22 @@ +function noRelativeBacktrack(template) { + if (!template.css) return []; + const errors = []; + const pattern = /url\s*\(\s*['"]?(\.\.)/gi; + let match; + while ((match = pattern.exec(template.css.content)) !== null) { + const start = match.index; + const excerpt = template.css.content.slice(start, start + 60).replace(/\n/g, ' '); + errors.push({ + rule: 'noRelativeBacktrack', + message: `Path com ../ bloqueado em CSS — quebra quando servido pelo handler: ${excerpt.trim()}`, + file: template.css.path, + }); + } + return errors; +} + +noRelativeBacktrack.ruleName = 'noRelativeBacktrack'; +noRelativeBacktrack.describe = function(template) { + return template.css ? 'CSS' : 'sem CSS'; +}; +module.exports = noRelativeBacktrack; diff --git a/packages/validator/src/rules/requiredFiles.js b/packages/validator/src/rules/requiredFiles.js new file mode 100644 index 0000000..6d82664 --- /dev/null +++ b/packages/validator/src/rules/requiredFiles.js @@ -0,0 +1,36 @@ +function requiredFiles(template) { + const errors = []; + + if (!template.html) { + errors.push({ + rule: 'requiredFiles', + message: 'payment.html não encontrado (esperado em partials/payment.html ou payment.html)', + severity: 'error', + }); + } + + if (!template.i18n) { + errors.push({ + rule: 'requiredFiles', + message: 'i18n/pt-BR.json não encontrado', + severity: 'error', + }); + } + + if (!template.icon) { + errors.push({ + rule: 'requiredFiles', + message: 'ícone não encontrado (esperado em assets/img/icon.png ou icon.png)', + severity: 'warning', + }); + } + + return errors; +} + +requiredFiles.ruleName = 'requiredFiles'; +requiredFiles.describe = function(template) { + const present = ['html', 'i18n', 'icon'].filter(k => template[k]).length; + return `${present}/3 arquivos obrigatórios`; +}; +module.exports = requiredFiles; diff --git a/packages/validator/src/runner.js b/packages/validator/src/runner.js new file mode 100644 index 0000000..49d1064 --- /dev/null +++ b/packages/validator/src/runner.js @@ -0,0 +1,77 @@ +const fs = require('fs'); +const path = require('path'); +const { walkFiles, DEFAULT_IGNORED_PATHS } = require('./walkFiles'); + +function readSlot(dir, ...candidates) { + for (const rel of candidates) { + const fullPath = path.join(dir, rel); + if (!fs.existsSync(fullPath)) continue; + const stat = fs.statSync(fullPath); + if (!stat.isFile()) continue; + return { + path: fullPath, + size: stat.size, + get content() { return fs.readFileSync(fullPath, 'utf8'); }, + get buffer() { return fs.readFileSync(fullPath); }, + }; + } + return null; +} + +function readTemplate(dir, opts = {}) { + const ignoredPaths = opts.ignore ? new Set(opts.ignore) : DEFAULT_IGNORED_PATHS; + + const html = readSlot(dir, 'partials/payment.html', 'payment.html'); + const css = readSlot(dir, 'assets/css/less/style.css', 'style.css'); + const i18n = readSlot(dir, 'i18n/pt-BR.json', 'i18n.json'); + const icon = readSlot(dir, 'assets/img/icon.png', 'icon.png'); + + const i18nDir = path.join(dir, 'i18n'); + const i18nFiles = fs.existsSync(i18nDir) + ? fs.readdirSync(i18nDir) + .filter(f => f.endsWith('.json')) + .sort() + .map(f => { + const fullPath = path.join(i18nDir, f); + return { + path: fullPath, + name: f, + size: fs.statSync(fullPath).size, + get content() { return fs.readFileSync(fullPath, 'utf8'); }, + }; + }) + : []; + + const known = new Set([html, css, i18n, icon].filter(Boolean).map(f => f.path)); + const assets = walkFiles(dir, dir, ignoredPaths).filter(f => !known.has(f.path)); + + return { html, css, i18n, i18nFiles, icon, assets, templateDir: dir }; +} + +function run(templateDir, rules, opts = {}) { + const template = readTemplate(templateDir, opts); + + const activeRules = opts.rules + ? rules.filter(r => opts.rules.includes(r.ruleName)) + : rules; + + const details = activeRules.map(rule => { + let errors; + try { + errors = rule(template, opts); + } catch (err) { + errors = [{ + rule: rule.ruleName || 'unknown', + message: `Erro interno na rule: ${err.message}`, + severity: 'warning', + }]; + } + const description = rule.describe ? rule.describe(template) : null; + const hasError = errors.some(e => !e.severity || e.severity === 'error'); + return { rule: rule.ruleName, ok: !hasError, errors, description }; + }); + + return { template, details, errors: details.flatMap(d => d.errors) }; +} + +module.exports = { readTemplate, run }; diff --git a/packages/validator/src/tests/assetsReferenced.test.js b/packages/validator/src/tests/assetsReferenced.test.js new file mode 100644 index 0000000..6d5246a --- /dev/null +++ b/packages/validator/src/tests/assetsReferenced.test.js @@ -0,0 +1,86 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const os = require('os'); +const fs = require('fs'); +const path = require('path'); +const assetsReferenced = require('../rules/assetsReferenced'); + +function makeTemplate({ htmlContent = null, cssContent = null, assetNames = [], templateDir } = {}) { + const dir = templateDir || fs.mkdtempSync(path.join(os.tmpdir(), 'ptv-')); + for (const name of assetNames) { + const full = path.join(dir, name); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, 'x'); + } + const assets = assetNames.map(name => ({ path: path.join(dir, name), size: 1 })); + return { + html: htmlContent ? { path: path.join(dir, 'payment.html'), size: htmlContent.length, content: htmlContent } : null, + css: cssContent ? { path: path.join(dir, 'style.css'), size: cssContent.length, content: cssContent } : null, + i18nFiles: [], icon: null, assets, templateDir: dir, + }; +} + +test('assetsReferenced — passa quando todos assets são referenciados no HTML', () => { + const t = makeTemplate({ + htmlContent: '', + assetNames: ['logo.png'], + }); + assert.equal(assetsReferenced(t).length, 0); +}); + +test('assetsReferenced — passa quando asset referenciado no CSS', () => { + const t = makeTemplate({ + cssContent: '.x { background: url("bg.png"); }', + assetNames: ['bg.png'], + }); + assert.equal(assetsReferenced(t).length, 0); +}); + +test('assetsReferenced — erro para referência quebrada (arquivo não existe)', () => { + const t = makeTemplate({ + htmlContent: '', + assetNames: ['logo.png'], + }); + const errors = assetsReferenced(t); + assert.ok(errors.some(e => e.rule === 'assetsReferenced' && e.severity === 'error' && /logoo\.png/.test(e.message))); +}); + +test('assetsReferenced — warning para asset órfão (não referenciado)', () => { + const t = makeTemplate({ + htmlContent: '
Pagar
', + assetNames: ['banner.png'], + }); + const errors = assetsReferenced(t); + assert.ok(errors.some(e => e.rule === 'assetsReferenced' && e.severity === 'warning' && /banner\.png/.test(e.message))); +}); + +test('assetsReferenced — ignora URLs externas nas referências', () => { + const t = makeTemplate({ + htmlContent: '', + assetNames: [], + }); + assert.equal(assetsReferenced(t).length, 0); +}); + +test('assetsReferenced — ignora data: URIs', () => { + const t = makeTemplate({ + htmlContent: '', + assetNames: [], + }); + assert.equal(assetsReferenced(t).length, 0); +}); + +test('assetsReferenced — retorna vazio sem html e css', () => { + const t = makeTemplate({ assetNames: ['bg.png'] }); + // No HTML/CSS → no references to collect, but orphan warning expected for image + const errors = assetsReferenced(t); + assert.ok(errors.every(e => e.severity === 'warning')); +}); + +test('assetsReferenced — não reporta non-image como órfão', () => { + const t = makeTemplate({ + htmlContent: '
ok
', + assetNames: ['data.json'], + }); + assert.equal(assetsReferenced(t).length, 0); +}); diff --git a/packages/validator/src/tests/cssScope.test.js b/packages/validator/src/tests/cssScope.test.js new file mode 100644 index 0000000..eb5d5f0 --- /dev/null +++ b/packages/validator/src/tests/cssScope.test.js @@ -0,0 +1,52 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const cssScope = require('../rules/cssScope'); + +function tmpl(cssContent) { + return { html: null, css: cssContent ? { path: '/tmp/style.css', size: cssContent.length, content: cssContent } : null, + i18nFiles: [], icon: null, assets: [], templateDir: '/tmp' }; +} + +test('cssScope — passa com seletor scoped', () => { + assert.equal(cssScope(tmpl('[data-payment-template] .btn { color: blue; }')).length, 0); +}); + +test('cssScope — passa com seletor aninhado via espaço', () => { + assert.equal(cssScope(tmpl('[data-payment-template] h3 { margin: 0; }')).length, 0); +}); + +test('cssScope — aviso com seletor não-scoped (body)', () => { + const errors = cssScope(tmpl('body { background: red; }')); + assert.equal(errors.length, 1); + assert.equal(errors[0].rule, 'cssScope'); + assert.equal(errors[0].severity, 'warning'); + assert.match(errors[0].message, /body/); +}); + +test('cssScope — aviso com seletor de classe não-scoped', () => { + const errors = cssScope(tmpl('.payment-title { color: red; }')); + assert.ok(errors.some(e => e.rule === 'cssScope')); +}); + +test('cssScope — ignora @keyframes', () => { + assert.equal(cssScope(tmpl('@keyframes fade { from { opacity: 0; } to { opacity: 1; } }')).length, 0); +}); + +test('cssScope — ignora @font-face', () => { + assert.equal(cssScope(tmpl('@font-face { font-family: "X"; src: url("/f.woff2"); }')).length, 0); +}); + +test('cssScope — ignora @media com seletor scoped interno', () => { + const css = '@media (max-width: 768px) { [data-payment-template] .btn { display: block; } }'; + assert.equal(cssScope(tmpl(css)).length, 0); +}); + +test('cssScope — aviso dentro de @media com seletor não-scoped', () => { + const css = '@media (max-width: 768px) { body { margin: 0; } }'; + const errors = cssScope(tmpl(css)); + assert.ok(errors.some(e => e.rule === 'cssScope')); +}); + +test('cssScope — retorna vazio quando css é null', () => { + assert.equal(cssScope(tmpl(null)).length, 0); +}); diff --git a/packages/validator/src/tests/htmlSafety.test.js b/packages/validator/src/tests/htmlSafety.test.js new file mode 100644 index 0000000..e12f708 --- /dev/null +++ b/packages/validator/src/tests/htmlSafety.test.js @@ -0,0 +1,50 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const htmlSafety = require('../rules/htmlSafety'); + +function makeHtmlTemplate(htmlContent) { + return { + html: { path: '/tmp/payment.html', size: htmlContent.length, content: htmlContent }, + css: null, + i18nFiles: [], + icon: null, + assets: [], + }; +} + +test('htmlSafety — passa com HTML limpo', () => { + const template = makeHtmlTemplate('
Pagamento
'); + assert.equal(htmlSafety(template).length, 0); +}); + +test('htmlSafety — bloqueia tag '); + const errors = htmlSafety(template); + assert.equal(errors.length, 1); + assert.equal(errors[0].rule, 'htmlSafety'); + assert.match(errors[0].message, /' }); + const errors = noExternalRefs(template); + assert.ok(errors.some(e => e.rule === 'noExternalRefs')); +}); + +test('noExternalRefs — bloqueia href externo em ', () => { + const template = makeTemplate({ htmlContent: '' }); + const errors = noExternalRefs(template); + assert.ok(errors.some(e => e.rule === 'noExternalRefs')); +}); + +test('noExternalRefs — bloqueia url() externo em CSS', () => { + const template = makeTemplate({ cssContent: 'div { background: url("https://cdn.com/img.png"); }' }); + const errors = noExternalRefs(template); + assert.equal(errors.length, 1); + assert.match(errors[0].message, /URL externa bloqueada em CSS/); +}); + +test('noExternalRefs — bloqueia @import externo em CSS', () => { + const template = makeTemplate({ cssContent: '@import "https://fonts.googleapis.com/css2?family=Roboto";' }); + const errors = noExternalRefs(template); + assert.ok(errors.some(e => /import externo/.test(e.message))); +}); + +test('noExternalRefs — detecta múltiplos erros no mesmo template', () => { + const template = makeTemplate({ + htmlContent: '', + }); + const errors = noExternalRefs(template); + assert.equal(errors.length, 2); +}); + +test('noExternalRefs — retorna vazio quando html e css são null', () => { + const template = makeTemplate(); + assert.equal(noExternalRefs(template).length, 0); +}); diff --git a/packages/validator/src/tests/noInlineStyles.test.js b/packages/validator/src/tests/noInlineStyles.test.js new file mode 100644 index 0000000..191afde --- /dev/null +++ b/packages/validator/src/tests/noInlineStyles.test.js @@ -0,0 +1,28 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const noInlineStyles = require('../rules/noInlineStyles'); + +function tmpl(htmlContent) { + return { html: htmlContent ? { path: '/tmp/payment.html', size: htmlContent.length, content: htmlContent } : null, + css: null, i18nFiles: [], icon: null, assets: [], templateDir: '/tmp' }; +} + +test('noInlineStyles — passa com HTML sem style=', () => { + assert.equal(noInlineStyles(tmpl('

Pagar

')).length, 0); +}); + +test('noInlineStyles — bloqueia style= em div', () => { + const errors = noInlineStyles(tmpl('
Pagar
')); + assert.equal(errors.length, 1); + assert.equal(errors[0].rule, 'noInlineStyles'); + assert.match(errors[0].message, /style=/i); +}); + +test('noInlineStyles — bloqueia style= em qualquer tag', () => { + const errors = noInlineStyles(tmpl('')); + assert.ok(errors.some(e => e.rule === 'noInlineStyles')); +}); + +test('noInlineStyles — retorna vazio quando html é null', () => { + assert.equal(noInlineStyles(tmpl(null)).length, 0); +}); diff --git a/packages/validator/src/tests/noRelativeBacktrack.test.js b/packages/validator/src/tests/noRelativeBacktrack.test.js new file mode 100644 index 0000000..dd91197 --- /dev/null +++ b/packages/validator/src/tests/noRelativeBacktrack.test.js @@ -0,0 +1,32 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const noRelativeBacktrack = require('../rules/noRelativeBacktrack'); + +function tmpl(cssContent) { + return { html: null, css: cssContent ? { path: '/tmp/style.css', size: cssContent.length, content: cssContent } : null, + i18nFiles: [], icon: null, assets: [], templateDir: '/tmp' }; +} + +test('noRelativeBacktrack — passa com url() relativo simples', () => { + assert.equal(noRelativeBacktrack(tmpl('div { background: url("./img/bg.png"); }')).length, 0); +}); + +test('noRelativeBacktrack — passa com url() absoluto root-relative', () => { + assert.equal(noRelativeBacktrack(tmpl('div { background: url("/assets/img/bg.png"); }')).length, 0); +}); + +test('noRelativeBacktrack — bloqueia url() com ../', () => { + const errors = noRelativeBacktrack(tmpl('div { background: url("../../img/logo.png"); }')); + assert.equal(errors.length, 1); + assert.equal(errors[0].rule, 'noRelativeBacktrack'); + assert.match(errors[0].message, /\.\.\//); +}); + +test('noRelativeBacktrack — bloqueia url() com .. sem barra', () => { + const errors = noRelativeBacktrack(tmpl("div { background: url('../img/logo.png'); }")); + assert.ok(errors.some(e => e.rule === 'noRelativeBacktrack')); +}); + +test('noRelativeBacktrack — retorna vazio quando css é null', () => { + assert.equal(noRelativeBacktrack(tmpl(null)).length, 0); +}); diff --git a/packages/validator/src/tests/requiredFiles.test.js b/packages/validator/src/tests/requiredFiles.test.js new file mode 100644 index 0000000..9dd2eca --- /dev/null +++ b/packages/validator/src/tests/requiredFiles.test.js @@ -0,0 +1,44 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const requiredFiles = require('../rules/requiredFiles'); + +function tmpl(overrides = {}) { + return { html: null, css: null, i18n: null, i18nFiles: [], icon: null, assets: [], templateDir: '/tmp', ...overrides }; +} + +const HTML = { path: '/tmp/partials/payment.html', size: 100 }; +const I18N = { path: '/tmp/i18n/pt-BR.json', size: 50 }; +const ICON = { path: '/tmp/assets/img/icon.png', size: 200 }; + +test('requiredFiles — passa quando html, i18n e icon estão presentes', () => { + assert.equal(requiredFiles(tmpl({ html: HTML, i18n: I18N, icon: ICON })).length, 0); +}); + +test('requiredFiles — erro quando payment.html está ausente', () => { + const errors = requiredFiles(tmpl({ i18n: I18N, icon: ICON })); + assert.equal(errors.length, 1); + assert.equal(errors[0].rule, 'requiredFiles'); + assert.match(errors[0].message, /payment\.html/); + assert.equal(errors[0].severity, 'error'); +}); + +test('requiredFiles — erro quando i18n/pt-BR.json está ausente', () => { + const errors = requiredFiles(tmpl({ html: HTML, icon: ICON })); + assert.equal(errors.length, 1); + assert.match(errors[0].message, /pt-BR\.json/); + assert.equal(errors[0].severity, 'error'); +}); + +test('requiredFiles — warning quando ícone está ausente', () => { + const errors = requiredFiles(tmpl({ html: HTML, i18n: I18N })); + assert.equal(errors.length, 1); + assert.match(errors[0].message, /icon/i); + assert.equal(errors[0].severity, 'warning'); +}); + +test('requiredFiles — acumula múltiplos erros', () => { + const errors = requiredFiles(tmpl()); + assert.ok(errors.length >= 2); + assert.ok(errors.some(e => /payment\.html/.test(e.message))); + assert.ok(errors.some(e => /pt-BR\.json/.test(e.message))); +}); diff --git a/packages/validator/src/tests/validate.test.js b/packages/validator/src/tests/validate.test.js new file mode 100644 index 0000000..a7cf30e --- /dev/null +++ b/packages/validator/src/tests/validate.test.js @@ -0,0 +1,167 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const os = require('os'); +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); +const { validate } = require('../index'); + +const REPO_ROOT = path.resolve(__dirname, '../../../..'); +const SRC_DIR = path.join(REPO_ROOT, 'src'); +const CLI = path.join(REPO_ROOT, 'validator', 'cli.js'); + +// ─── helpers ─────────────────────────────────────────────────────────────── + +function makeTmpTemplate(files) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ptv-')); + for (const [rel, content] of Object.entries(files)) { + const fullPath = path.join(tmpDir, rel); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); + } + return tmpDir; +} + +const VALID_HTML = '

Pay

'; +const VALID_I18N = JSON.stringify({ 'payment.title': 'Pagamento' }); + +// ─── US-3: validate() API (lib) ──────────────────────────────────────────── + +// Synthetic PNG header with 80x80 dimensions (valid for iconDimensions rule) +function makePngBuf(w, h) { + const buf = Buffer.alloc(24); + buf.write('\x89PNG\r\n\x1a\n', 0, 'binary'); + buf.writeUInt32BE(13, 8); + buf.write('IHDR', 12, 'ascii'); + buf.writeUInt32BE(w, 16); + buf.writeUInt32BE(h, 20); + return buf; +} +const VALID_PNG = makePngBuf(80, 80); + +test('validate() — retorna ok:true para template válido', async () => { + const dir = makeTmpTemplate({ + 'partials/payment.html': VALID_HTML, + 'i18n/pt-BR.json': VALID_I18N, + 'i18n/en-US.json': JSON.stringify({ 'payment.title': 'Payment' }), + 'assets/img/icon.png': VALID_PNG, + }); + const result = await validate(dir); + assert.equal(result.ok, true); + assert.equal(result.errors.filter(e => !e.severity || e.severity === 'error').length, 0); +}); + +test('validate() — retorna ok:false para template com erro de maxFileSize', async () => { + const bigContent = Buffer.alloc(200 * 1024, 'x'); + const dir = makeTmpTemplate({ + 'partials/payment.html': VALID_HTML, + 'big.bin': bigContent, + }); + const result = await validate(dir); + assert.equal(result.ok, false); + assert.ok(result.errors.some(e => e.rule === 'maxFileSize')); +}); + +test('validate() — retorna ok:false para i18n inconsistente', async () => { + const dir = makeTmpTemplate({ + 'partials/payment.html': VALID_HTML, + 'i18n/pt-BR.json': JSON.stringify({ a: '1', b: '2' }), + 'i18n/es.json': JSON.stringify({ a: 'Alpha' }), + }); + const result = await validate(dir); + assert.equal(result.ok, false); + assert.ok(result.errors.some(e => e.rule === 'i18nKeyConsistency')); +}); + +test('validate() — retorna ok:false para HTML com script externo', async () => { + const dir = makeTmpTemplate({ + 'partials/payment.html': '
', + }); + const result = await validate(dir); + assert.equal(result.ok, false); + assert.ok(result.errors.some(e => e.rule === 'htmlSafety' || e.rule === 'noExternalRefs')); +}); + +test('validate() — retorna ok:false para HTML com onclick inline', async () => { + const dir = makeTmpTemplate({ + 'partials/payment.html': '', + }); + const result = await validate(dir); + assert.ok(result.errors.some(e => e.rule === 'htmlSafety')); +}); + +test('validate() — retorna ok:false para URL externa em CSS', async () => { + const dir = makeTmpTemplate({ + 'partials/payment.html': VALID_HTML, + 'style.css': 'div { background: url("https://cdn.com/img.png"); }', + }); + const result = await validate(dir); + assert.ok(result.errors.some(e => e.rule === 'noExternalRefs')); +}); + +test('validate() — opts.maxFileSize sobrescreve limite padrão', async () => { + const dir = makeTmpTemplate({ + 'partials/payment.html': VALID_HTML, + 'small.bin': Buffer.alloc(5 * 1024, 'x'), + }); + const result = await validate(dir, { maxFileSize: 1 * 1024 }); + assert.ok(result.errors.some(e => e.rule === 'maxFileSize')); +}); + +// ─── US-1: CLI com template default de src/ ──────────────────────────────── + +test('CLI — src/ do payment-mocker passa (exit 0)', () => { + const out = execSync(`node "${CLI}" "${SRC_DIR}"`, { encoding: 'utf8' }); + assert.match(out, /template passou/); +}); + +// ─── US-2: CLI com --json ────────────────────────────────────────────────── + +test('CLI --json — retorna JSON ok:true para template válido', () => { + const out = execSync(`node "${CLI}" "${SRC_DIR}" --json`, { encoding: 'utf8' }); + const result = JSON.parse(out); + assert.equal(result.ok, true); + assert.ok(Array.isArray(result.errors)); +}); + +test('CLI --json — retorna JSON ok:false para template inválido (exit 1)', () => { + const bigContent = Buffer.alloc(200 * 1024, 'x'); + const dir = makeTmpTemplate({ + 'partials/payment.html': VALID_HTML, + 'big.bin': bigContent, + }); + + let threw = false; + try { + execSync(`node "${CLI}" "${dir}" --json`, { encoding: 'utf8' }); + } catch (err) { + threw = true; + assert.equal(err.status, 1); + const result = JSON.parse(err.stdout); + assert.equal(result.ok, false); + assert.ok(result.errors.length > 0); + } + assert.ok(threw, 'CLI deveria ter retornado exit 1'); +}); + +test('CLI — exit 2 quando diretório não existe', () => { + let threw = false; + try { + execSync(`node "${CLI}" /tmp/nao-existe-ptv-xyzabc`, { encoding: 'utf8' }); + } catch (err) { + threw = true; + assert.equal(err.status, 2); + } + assert.ok(threw); +}); + +test('CLI — exit 2 quando nenhum argumento passado', () => { + let threw = false; + try { + execSync(`node "${CLI}"`, { encoding: 'utf8' }); + } catch (err) { + threw = true; + assert.equal(err.status, 2); + } + assert.ok(threw); +}); diff --git a/packages/validator/src/walkFiles.js b/packages/validator/src/walkFiles.js new file mode 100644 index 0000000..93ff3e2 --- /dev/null +++ b/packages/validator/src/walkFiles.js @@ -0,0 +1,42 @@ +const fs = require('fs'); +const path = require('path'); + +const IGNORED_NAMES = new Set(['.DS_Store', '.git', 'node_modules']); + +const DEFAULT_IGNORED_PATHS = new Set([ + 'index.html', + 'checkout-style.css', + 'assets/libs', + 'assets/css/sass', + 'assets/css/less/style.less', +]); + +function isIgnoredPath(absolutePath, rootDir, ignoredPaths) { + const rel = path.relative(rootDir, absolutePath); + if (ignoredPaths.has(rel)) return true; + for (const ignored of ignoredPaths) { + if (rel === ignored || rel.startsWith(ignored + path.sep)) return true; + } + return false; +} + +function walkFiles(dir, rootDir = dir, ignoredPaths = DEFAULT_IGNORED_PATHS) { + const out = []; + const visit = (d) => { + for (const entry of fs.readdirSync(d, { withFileTypes: true })) { + if (IGNORED_NAMES.has(entry.name)) continue; + const full = path.join(d, entry.name); + if (isIgnoredPath(full, rootDir, ignoredPaths)) continue; + if (entry.isDirectory()) { + visit(full); + } else if (entry.isFile()) { + const stat = fs.statSync(full); + out.push({ path: full, size: stat.size }); + } + } + }; + visit(dir); + return out; +} + +module.exports = { walkFiles, DEFAULT_IGNORED_PATHS, IGNORED_NAMES }; diff --git a/specs/payment-template-validator.md b/specs/payment-template-validator.md new file mode 100644 index 0000000..5644173 --- /dev/null +++ b/specs/payment-template-validator.md @@ -0,0 +1,293 @@ +# payment-template-validator + +> **Status**: Done +> **Created**: 2026-06-02 + +## 1. Business Context + +### Problem Statement + +Parceiros que desenvolvem templates de pagamento para o VTEX Smart Checkout só descobrem erros de validação depois de abrir ticket para a VTEX. Cada ciclo de erro/correção adiciona 5–10 dias úteis ao tempo de release. + +Além disso, a validação precisa rodar em dois lugares — CLI local do parceiro e handler server-side da VTEX — e precisa ser a **mesma** validação. Hoje o `payment-templates-handler` (Squad A) não roda validação alguma; se cada lado implementar regras separadas, elas divergem em semanas e o parceiro só descobre em produção. + +### Goals + +- Parceiro detecta erros de template localmente antes de abrir qualquer ticket. +- VTEX bloqueia uploads inválidos no server usando as mesmas regras do CLI do parceiro. +- Regras novas são adicionadas em um único lugar e propagadas para os dois clientes automaticamente. +- CLI retorna exit code 0/1 adequado para integração em pipelines de CI/CD do parceiro. + +### User Stories + +#### US-1: Validação local pelo parceiro + +- **Story**: Como parceiro de pagamento, quero rodar uma validação local do meu template antes de submetê-lo, para que eu corrija erros sem abrir ticket na VTEX. +- **Acceptance Criteria**: + - **Given** um diretório de template válido, **when** executo `node validator/cli.js src/`, **then** vejo `✓ template em passou em todas as validações` e exit code 0. + - **Given** um template com arquivo maior que 128 KB, **when** executo a CLI, **then** vejo mensagem descritiva de erro e exit code 1. + - **Given** um template com chaves i18n inconsistentes entre locales, **when** executo a CLI, **then** recebo erro `i18nKeyConsistency` apontando qual arquivo e quais chaves estão faltando. + - **Given** um template com `