From a7690ae8866044709dca88bf52e6096cbcd952bb Mon Sep 17 00:00:00 2001 From: Fluxionics Date: Fri, 9 Oct 2026 13:08:27 -0600 Subject: [PATCH 1/4] Permission Lab y Pre-Build Doctor Lab: audit distingue declarado/concedido/probado + rango de Android por permiso con deteccion de APIs desde URL/HTML; Doctor: POST /api/doctor con semaforo (config, permisos, HTML, seguridad, APIs) y card en UI; tests 119/119 --- index.html | 13 ++++++ js/modules/doctor.js | 33 ++++++++++++++ js/modules/permissions.js | 7 ++- server/doctor.js | 94 +++++++++++++++++++++++++++++++++++++++ server/generator/audit.js | 27 ++++++++++- server/routes/catalog.js | 63 ++++++++++++++++++++++++-- tests/doctor.test.js | 68 ++++++++++++++++++++++++++++ tests/permissions.test.js | 23 ++++++++++ 8 files changed, 321 insertions(+), 7 deletions(-) create mode 100644 js/modules/doctor.js create mode 100644 server/doctor.js create mode 100644 tests/doctor.test.js diff --git a/index.html b/index.html index 694f333..ca9d61d 100644 --- a/index.html +++ b/index.html @@ -611,6 +611,18 @@

Preguntas frecuentes

+
+
+ + Pre-Build Doctor PRO +
+

Chequea config, permisos, HTML/URL y seguridad antes de compilar. Semáforo: OK, aviso o error por punto.

+
+ +
+
+
+
@@ -1503,6 +1515,7 @@

Términos de Uso y Políticas

+ diff --git a/js/modules/doctor.js b/js/modules/doctor.js new file mode 100644 index 0000000..929182f --- /dev/null +++ b/js/modules/doctor.js @@ -0,0 +1,33 @@ +'use strict'; + +const Doctor = (() => { + const run = async () => { + const box = $('#doctorBox'); + if (!box) return; + box.innerHTML = 'Diagnosticando antes de compilar…'; + try { + const r = await fetch('/api/doctor', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(Form.collect()) + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.error || 'El Doctor falló'); + const ico = { ok: 'check', warn: 'alert', error: 'x' }; + const cls = { ok: 'ok', warn: 'warn', error: 'fail' }; + const rows = j.checks.map((c) => + `
${IBIcon(ico[c.status] || 'alert')}
${escHtml(c.label)}${c.detail ? `
${escHtml(c.detail)}
` : ''}
${c.status === 'error' ? 'error' : c.status}
` + ).join(''); + const barColor = j.canBuild ? (j.warns ? 'var(--warn)' : 'var(--success)') : 'var(--danger)'; + box.innerHTML = rows + + `
Score ${j.score}/100 · ${j.checks.length} chequeos · ${escHtml(j.summary)}
` + + `
`; + } catch (e) { + box.innerHTML = '' + escHtml(e.message) + ''; + } + }; + + on('#doctorBtn', 'click', run); + + return { run }; +})(); diff --git a/js/modules/permissions.js b/js/modules/permissions.js index b4fcb91..023b9dc 100644 --- a/js/modules/permissions.js +++ b/js/modules/permissions.js @@ -8,7 +8,12 @@ const Permissions = (() => { const manOk = (i.manifest || '').startsWith('GENERATED'); const runOk = (i.runtime || '').startsWith('GENERATED'); const natOk = (i.native || '').startsWith('GENERATED'); - return `
${icon}
${escHtml(i.title)} (${escHtml(i.key)}) ${i.verified ? '· verificado en APK' : '· NO generado'}
Manifest ${escHtml(i.manifest)}Runtime ${escHtml((i.runtime || '').slice(0, 70))}Native ${escHtml((i.native || i.handler || '').slice(0, 80))}Bridge ${escHtml((i.bridge || '').slice(0, 50))}${i.mechanism ? `${escHtml(i.mechanism)}` : ''}${i.version !== 'OK' ? `${escHtml(i.version)} · minSdk ${i.minSdk}` : ''}${i.special ? `${IBIcon('alert')} ${escHtml((i.special || '').slice(0, 90))}` : ''}${i.provider && i.provider.startsWith('WARN') ? `${escHtml(i.provider)}` : ''}
${i.status}
`; + const grantedChip = i.granted ? `Concedido: ${escHtml(i.granted)}` : ''; + const usedChip = i.used === 'detectado' ? 'Uso: detectado en tu web' + : i.used === 'sin-uso' ? 'Uso: sin uso detectado' + : i.used === 'sin-datos' ? 'Uso: sin analizar' : ''; + const rangeChip = i.rangeStatus === 'warn' && i.range ? `${IBIcon('alert')} ${escHtml(i.range)}` : ''; + return `
${icon}
${escHtml(i.title)} (${escHtml(i.key)}) ${i.verified ? '· verificado en APK' : '· NO generado'}
Manifest ${escHtml(i.manifest)}Runtime ${escHtml((i.runtime || '').slice(0, 70))}Native ${escHtml((i.native || i.handler || '').slice(0, 80))}Bridge ${escHtml((i.bridge || '').slice(0, 50))}${grantedChip}${usedChip}${rangeChip}${i.mechanism ? `${escHtml(i.mechanism)}` : ''}${i.version !== 'OK' ? `${escHtml(i.version)} · minSdk ${i.minSdk}` : ''}${i.special ? `${IBIcon('alert')} ${escHtml((i.special || '').slice(0, 90))}` : ''}${i.provider && i.provider.startsWith('WARN') ? `${escHtml(i.provider)}` : ''}
${i.status}
`; }; const renderReadiness = async () => { diff --git a/server/doctor.js b/server/doctor.js new file mode 100644 index 0000000..4549703 --- /dev/null +++ b/server/doctor.js @@ -0,0 +1,94 @@ +'use strict'; + +function runDoctor(cfg, ctx){ + const audit = ctx.audit || {items:[], ok:0, total:0, canBuild:true}; + const html = ctx.html || ''; + const checks = []; + const add = (status, label, detail) => checks.push({status, label, detail: detail||''}); + + const inputType = cfg.inputType || 'url'; + const url = String(cfg.url||'').trim(); + const htmlSrc = String(cfg.htmlCode||cfg.html||''); + if(inputType==='url' && !url) add('error','Falta la URL del sitio','Pega la URL o cambia a HTML directo'); + else if(inputType==='html' && !htmlSrc) add('error','Falta el HTML','Pega tu codigo o cambia a URL'); + else if(inputType==='url' && ctx.urlError) add('error','La URL no responde', ctx.urlError); + else if(inputType==='url' && ctx.blockedUrl) add('error','URL bloqueada por seguridad', url); + else add('ok','Fuente web lista', inputType==='url' ? url : 'HTML directo ('+htmlSrc.length+' bytes)'); + + const pkg = String(cfg.packageName||''); + if(!pkg) add('warn','packageName vacio','Se usa el default del template'); + else if(!/^([a-z][a-z0-9_]*\.)+[a-z][a-z0-9_]*$/.test(pkg)) add('error','packageName invalido', pkg+' — formato: com.tuempresa.tuapp'); + else add('ok','packageName valido', pkg); + + const appName = String(cfg.appName||'').trim(); + if(!appName) add('warn','Falta el nombre de la app','Se usa un nombre por defecto'); + else if(appName.length>30) add('warn','Nombre largo para Play Store', appName.length+' caracteres (Play recomienda max 30)'); + else add('ok','Nombre de la app', appName); + + const ver = String(cfg.appVersion||cfg.version||'1.0'); + if(!/^\d+(\.\d+){0,2}$/.test(ver)) add('error','Version invalida', ver+' — usa formato 1.0 o 1.0.0'); + else add('ok','Version del APK', ver); + + const outputs = Array.isArray(cfg.outputs)&&cfg.outputs.length ? cfg.outputs : ['apk']; + add('ok','Salidas seleccionadas', outputs.join(', ')); + + if(cfg.nativeAudio && !String(cfg.streamUrl||'').trim()) add('error','Audio nativo sin streamUrl','Pon tu servidor en Audio nativo o desactivalo'); + else if(cfg.nativeAudio && !/^https?:\/\//.test(String(cfg.streamUrl||''))) add('error','streamUrl invalido','Debe empezar con http:// o https://'); + else if(cfg.nativeAudio) add('ok','Audio nativo con stream', String(cfg.streamUrl)); + + if(audit.total===0) add('ok','Sin permisos especiales','La app solo usa INTERNET'); + else{ + const fails = audit.items.filter(i=>i.status==='fail'); + const warns = audit.items.filter(i=>i.status==='warn'); + if(fails.length) add('error', fails.length+' permiso(s) sin generar', fails.map(i=>i.title).join(', ')); + else add('ok', audit.ok+'/'+audit.total+' permisos verificados', 'Manifest + runtime + handler generados'); + if(warns.length) add('warn', warns.length+' permiso(s) con avisos', warns.map(i=>i.title+(i.version!=='OK'?' (Android '+i.minSdk+'+)':'')).join(', ')); + const rangeWarn = audit.items.filter(i=>i.rangeStatus==='warn'); + if(rangeWarn.length) add('warn', 'Rango de Android incompleto', rangeWarn.map(i=>i.title+': '+i.range).join(', ')); + const unused = audit.items.filter(i=>i.used==='sin-uso'); + if(unused.length) add('warn', unused.length+' permiso(s) declarado(s) sin uso detectado', unused.map(i=>i.title).join(', ')); + } + + if(html){ + const sizeKB = Math.round(Buffer.byteLength(html,'utf8')/1024); + if(!/','Agrega como primera linea'); + else if(!/','Envuelve el contenido en ...'); + else add('ok','HTML valido (doctype + )', sizeKB+'KB'); + if(sizeKB>500) add('warn','HTML pesado', sizeKB+'KB — minifica antes de compilar'); + if(!/]*viewport/i.test(html)) add('warn','Sin meta viewport','Agrega '); + else add('ok','Viewport movil presente'); + const insecure = (html.match(/src=["']http:\/\//gi)||[]).length + (html.match(/href=["']http:\/\//gi)||[]).length; + if(insecure) add('warn', insecure+' recursos por http://','Cambia todos a https://'); + else add('ok','Sin recursos inseguros (http://)'); + + if(ctx.errors){ + if(ctx.errors.errors.length) add('error', ctx.errors.errors.length+' error(es) de HTML', ctx.errors.errors.map(e=>e.msg).join(' | ')); + if(ctx.errors.warnings.length) add('warn', ctx.errors.warnings.length+' aviso(s) de HTML', ctx.errors.warnings.slice(0,5).map(w=>w.msg).join(' | ')); + } + if(ctx.security){ + const crit = ctx.security.issues.filter(i=>i.severity==='critical'); + const high = ctx.security.issues.filter(i=>i.severity==='high'); + const rest = ctx.security.issues.filter(i=>i.severity!=='critical'&&i.severity!=='high'); + if(crit.length) add('error','Seguridad critica', crit.map(i=>i.msg).join(' | ')); + else add('ok','Sin fallos de seguridad criticos', 'score '+ctx.security.score+'/100'); + if(high.length) add('warn', high.length+' fallo(s) de seguridad altos', high.map(i=>i.msg).join(' | ')); + if(rest.length) add('warn', rest.length+' aviso(s) de seguridad', rest.map(i=>i.msg).join(' | ')); + } + if(Array.isArray(ctx.detectedApis)){ + const recs = (ctx.suggest||[]).filter(r=>r.key && !cfg.permissions?.[r.key]); + if(recs.length) add('warn','API detectada sin permiso activo', recs.map(r=>r.spec&&r.spec.title?r.spec.title:r.key).join(', ')); + else add('ok','Permisos coherentes con las APIs detectadas', ctx.detectedApis.length ? ctx.detectedApis.join(', ') : 'ninguna API con permiso requerido'); + } + } + + const score = checks.length ? Math.round(checks.reduce((s,c)=>s+(c.status==='ok'?1:c.status==='warn'?0.5:0),0)/checks.length*100) : 0; + const errors = checks.filter(c=>c.status==='error').length; + const warns = checks.filter(c=>c.status==='warn').length; + const canBuild = errors===0; + const summary = errors ? errors+' error(es) que impiden compilar' + : warns ? 'Listo, con '+warns+' aviso(s) recomendados revisar' + : 'Listo para compilar'; + return {checks, score, canBuild, errors, warns, summary}; +} + +module.exports = { runDoctor }; diff --git a/server/generator/audit.js b/server/generator/audit.js index 3763c1f..ef32e8c 100644 --- a/server/generator/audit.js +++ b/server/generator/audit.js @@ -3,9 +3,26 @@ const { PERMISSION_SPEC, BG_LOCATION, MANAGE_STORAGE, runtimeBatchConsts, needsSpecialFile } = require('./permissions'); const { permissionManifestBlocks } = require('./manifest'); -function getPermissionAudit(cfg){ +const API_OF = { + gps:'geolocation', gpsBackground:'geolocation', accessFineLocation:'geolocation', accessCoarseLocation:'geolocation', advGeo:'geolocation', + cameraMic:'camera', microphone:'microphone', voiceRec:'microphone', + bluetooth:'bluetooth', bluetoothScan:'bluetooth', bluetoothConnect:'bluetooth', bluetoothAdvertise:'bluetooth', + nfc:'nfc', notifications:'notifications', vibration:'vibration', wakeLock:'wakelock', + ar:'camera' +}; +const GRANTED_LABEL = { + 'install-time':'Automatico al instalar', + 'runtime':'Dialogo al ejecutar', + 'special':'Manual en Settings', + 'background':'Dialogo foreground + background', + 'missing':'No generado' +}; + +function getPermissionAudit(cfg, opts){ + const detectedApis = opts && Array.isArray(opts.detectedApis) ? opts.detectedApis : undefined; const selected = Object.entries(cfg.permissions||{}).filter(([,v])=>v).map(([k])=>k); const targetSdk = cfg.targetSdk||35; + const projectMinSdk = cfg.minSdk||23; const provider = cfg.provider||'capacitor'; let manifestXml=''; try{ manifestXml = permissionManifestBlocks(cfg); }catch{ manifestXml=''; } @@ -69,7 +86,13 @@ function getPermissionAudit(cfg){ if(key==='gpsBackground' && !cfg.permissions.gps) status='fail'; if(key==='accessBackgroundLocation' && !(cfg.permissions.gps || cfg.permissions.accessFineLocation || cfg.permissions.accessCoarseLocation)) status='fail'; if(key==='advGeo' && !(cfg.permissions.gps || cfg.permissions.accessFineLocation || cfg.permissions.accessCoarseLocation)) status='warn'; - return {key, title:spec.title, manifest, runtime, native, bridge, capability, mechanism, handler:spec.handler, version, special:spec.specialAccess||spec.legacyNote||null, provider:providerOk, status, minSdk:spec.minSdk, api:spec.api, verified}; + const apiOf = API_OF[key] || null; + let used = 'sin-datos'; + if (Array.isArray(detectedApis)) used = !apiOf ? 'n/a' : (detectedApis.includes(apiOf) ? 'detectado' : 'sin-uso'); + const granted = GRANTED_LABEL[mechanism] || GRANTED_LABEL['install-time']; + const rangeWarn = !!(spec.minSdk && spec.minSdk > projectMinSdk); + const range = rangeWarn ? 'No funciona en Android ' + spec.minSdk + ' (tu minSdk ' + projectMinSdk + ')' : null; + return {key, title:spec.title, manifest, runtime, native, bridge, capability, mechanism, handler:spec.handler, version, special:spec.specialAccess||spec.legacyNote||null, provider:providerOk, status, minSdk:spec.minSdk, api:spec.api, verified, granted, used, apiOf, range, rangeStatus: rangeWarn?'warn':'ok'}; }); const ok=res.filter(r=>r.status==='ok').length; const total=res.length; diff --git a/server/routes/catalog.js b/server/routes/catalog.js index 6acb4a2..b1c1893 100644 --- a/server/routes/catalog.js +++ b/server/routes/catalog.js @@ -1,9 +1,10 @@ 'use strict'; const { loadZipGuarded, entryBuffer } = require('../zip-guard'); +const { runDoctor } = require('../doctor'); module.exports = function registerCatalogRoutes(app, ctx) { - const { generator, templates, configFromBody, detectWebApis, isBlockedUrl, checkRateLimit } = ctx; + const { generator, templates, configFromBody, detectWebApis, isBlockedUrl, checkRateLimit, securityScan, errorDetection } = ctx; app.post('/api/manifest-diff', (req,res)=>{ @@ -122,11 +123,27 @@ module.exports = function registerCatalogRoutes(app, ctx) { app.get('/api/permissions/spec', (req,res)=> res.json(generator.PERMISSION_SPEC)); app.get('/api/permissions/droncito-defaults', (req,res)=> res.json({ essentials: generator.DRONCITO_ESSENTIALS })); - app.post('/api/permissions/audit', (req,res)=>{ + app.post('/api/permissions/audit', async (req,res)=>{ + const ip = req.ip || req.connection.remoteAddress || 'unknown'; try{ const cfg=configFromBody(req.body); - const audit=generator.getPermissionAudit(cfg); - res.json(audit); + let detectedApis; + const inputType=cfg.inputType||'url'; + const htmlIn=String(req.body.html||req.body.htmlCode||''); + const urlIn=String(req.body.url||'').trim(); + if(inputType==='html' && htmlIn){ + detectedApis=detectWebApis(htmlIn.slice(0,500000)); + } else if(inputType==='url' && urlIn && !isBlockedUrl(urlIn)){ + if(checkRateLimit(ip,'catalog')){ + try{ + const r=await fetch(urlIn,{headers:{'User-Agent':'InteeBuild-PermAudit/1.0'}, signal:AbortSignal.timeout(8000)}); + const t=await r.text(); + detectedApis=detectWebApis(t.slice(0,500000)); + }catch{} + } + } + const audit=generator.getPermissionAudit(cfg,{detectedApis}); + res.json({...audit, detectedApis: detectedApis||null}); }catch(e){ res.status(400).json({error:e.message}); } }); app.post('/api/permissions/suggest', async (req,res)=>{ @@ -176,4 +193,42 @@ module.exports = function registerCatalogRoutes(app, ctx) { res.json({readiness, checks:{...checks, nativeAudio:nativeAudioOk}, audit, warnings, canBuild: audit.canBuild && hasUrlOrHtml && nativeAudioOk, message: readiness>=90?'Listo para compilar': readiness>=70?'Recomendado revisar':'Corrige permisos'}); }catch(e){ res.status(400).json({error:e.message}); } }); + + app.post('/api/doctor', async (req,res)=>{ + const ip = req.ip || req.connection.remoteAddress || 'unknown'; + try{ + const cfg=configFromBody(req.body); + const audit=generator.getPermissionAudit(cfg); + const inputType=cfg.inputType||'url'; + const url=String(cfg.url||'').trim(); + const htmlSrc=String(cfg.htmlCode||''); + let html='', urlError=null, blockedUrl=false, detectedApis; + if(inputType==='html' && htmlSrc){ + html=htmlSrc.slice(0,600000); + } else if(inputType==='url' && url){ + if(isBlockedUrl(url)) blockedUrl=true; + else if(!checkRateLimit(ip,'catalog')) urlError='Rate limit de analisis (10/h por IP)'; + else{ + try{ + const r=await fetch(url,{headers:{'User-Agent':'InteeBuild-Doctor/1.0'}, redirect:'manual', signal:AbortSignal.timeout(8000)}); + if(r.status>=400) urlError='HTTP '+r.status; + else{ + const t=await r.text(); + html=t.slice(0,500000); + if(t.length>500000) urlError='HTML demasiado grande (max 500KB)'; + } + }catch(e){ urlError=e.message||'No responde'; } + } + } + let security=null, errors=null; + if(html){ + detectedApis=detectWebApis(html); + try{ security=securityScan(html,{}, inputType==='url'?url:'https://html-direct'); }catch{} + try{ errors=errorDetection(html); }catch{} + } + const suggest=generator.suggestPermissionsFromApis(detectedApis||[]); + const result=runDoctor(cfg,{audit, html, urlError, blockedUrl, detectedApis, security, errors, suggest}); + res.json(result); + }catch(e){ res.status(400).json({error:e.message}); } + }); }; diff --git a/tests/doctor.test.js b/tests/doctor.test.js new file mode 100644 index 0000000..dae2dbe --- /dev/null +++ b/tests/doctor.test.js @@ -0,0 +1,68 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const g = require('../server/generator'); +const { runDoctor } = require('../server/doctor'); + +const GOOD_HTML = '

ok

'; + +test('doctor: html sin doctype bloquea con error', () => { + const cfg = g.normalizeConfig({ appName: 'Doc', inputType: 'html', htmlCode: '
hola
' }); + const d = runDoctor(cfg, { audit: g.getPermissionAudit(cfg), html: '
hola
' }); + const doctype = d.checks.find(c => c.label.includes('DOCTYPE')); + assert.equal(doctype.status, 'error'); + assert.equal(d.canBuild, false, 'error de HTML impide compilar'); + assert.ok(d.score >= 0 && d.score <= 100); +}); + +test('doctor: config valida y html bueno pasa con cero errores', () => { + const cfg = g.normalizeConfig({ appName: 'Doc Buena', url: 'https://example.com', packageName: 'com.doc.buena', appVersion: '1.2.3', permissions: { gps: true } }); + const d = runDoctor(cfg, { + audit: g.getPermissionAudit(cfg), + html: GOOD_HTML, + detectedApis: ['geolocation'], + security: { score: 95, issues: [] }, + errors: { errors: [], warnings: [] }, + suggest: g.suggestPermissionsFromApis(['geolocation']) + }); + assert.equal(d.canBuild, true, JSON.stringify(d.checks.filter(c => c.status === 'error'))); + assert.equal(d.errors, 0); + const pkg = d.checks.find(c => c.label.includes('packageName')); + assert.equal(pkg.status, 'ok'); +}); + +test('doctor: packageName malo y API detectada sin permiso avisan o bloquean', () => { + const cfg = g.normalizeConfig({ appName: 'Doc Pkg', url: 'https://example.com', packageName: 'com.doc.pkg', permissions: {} }); + const d = runDoctor({ ...cfg, packageName: 'Paquete Malo' }, { + audit: g.getPermissionAudit(cfg), + html: GOOD_HTML, + detectedApis: ['bluetooth'], + suggest: g.suggestPermissionsFromApis(['bluetooth']) + }); + const pkg = d.checks.find(c => c.label.includes('packageName')); + assert.equal(pkg.status, 'error', 'packageName con mayusculas y espacios no pasa'); + const missing = d.checks.find(c => c.label.includes('API detectada sin permiso')); + assert.ok(missing, 'la API detectada sin permiso debe aparecer'); + assert.equal(d.canBuild, false); +}); + +test('doctor: sin fuente web el error es claro', () => { + const cfg = g.normalizeConfig({ appName: 'Doc Sin Fuente', url: 'https://example.com' }); + const d = runDoctor(cfg, { audit: g.getPermissionAudit(cfg) }); + const fuente = d.checks.find(c => c.label.includes('Fuente web')); + assert.equal(fuente.status, 'ok', 'con URL presente la fuente esta lista'); + const d2 = runDoctor({ ...cfg, url: '' }, { audit: g.getPermissionAudit(cfg) }); + assert.equal(d2.checks.find(c => c.label.includes('Falta la URL')).status, 'error'); + assert.equal(d2.canBuild, false); +}); + +test('doctor: permisos fallidos y avisos de rango se reflejan en los checks', () => { + const cfg = g.normalizeConfig({ appName: 'Doc Rangos', url: 'https://example.com', minSdk: 23, permissions: { gpsBackground: true, nearby: true } }); + const d = runDoctor(cfg, { audit: g.getPermissionAudit(cfg) }); + const fails = d.checks.find(c => c.status === 'error' && c.label.includes('sin generar')); + assert.ok(fails, 'gpsBackground sin gps es fail'); + const rango = d.checks.find(c => c.label.includes('Rango de Android')); + assert.ok(rango && rango.status === 'warn'); + assert.equal(d.canBuild, false); +}); diff --git a/tests/permissions.test.js b/tests/permissions.test.js index eb2d318..74b912b 100644 --- a/tests/permissions.test.js +++ b/tests/permissions.test.js @@ -114,3 +114,26 @@ test('grant WebView: Capacitor ya concede camara/mic, el parche no sustituye su assert.ok(!mj.includes('ok.add(r)'), 'nunca mete r (PermissionRequest) a la lista de String'); assert.ok(!mj.includes('r.contains('), 'PermissionRequest no tiene contains'); }); + +test('lab: declarado, concedido, probado y rango de Android por permiso', () => { + const cfg = g.normalizeConfig({ appName: 'Lab Test', url: 'https://example.com', minSdk: 23, permissions: { gps: true, cameraMic: true, storage: true, nearby: true } }); + const audit = g.getPermissionAudit(cfg, { detectedApis: ['geolocation'] }); + const gps = audit.items.find(i => i.key === 'gps'); + assert.equal(gps.granted, 'Dialogo al ejecutar', 'runtime se concede con dialogo'); + assert.equal(gps.used, 'detectado', 'geolocation detectada en la web cuenta como probado'); + const cam = audit.items.find(i => i.key === 'cameraMic'); + assert.equal(cam.used, 'sin-uso', 'declarado pero sin uso detectado'); + const store = audit.items.find(i => i.key === 'storage'); + assert.equal(store.used, 'n/a', 'sin API web comparable'); + const nearby = audit.items.find(i => i.key === 'nearby'); + assert.equal(nearby.rangeStatus, 'warn', 'minSdk del permiso mayor al del proyecto'); + assert.ok(/Android 33/.test(nearby.range) && /minSdk 23/.test(nearby.range), nearby.range); + const install = g.getPermissionAudit(g.normalizeConfig({ appName: 'Lab Test', url: 'https://example.com', permissions: { vibration: true } })); + const vib = install.items.find(i => i.key === 'vibration'); + assert.equal(vib.granted, 'Automatico al instalar', 'install-time no pide dialogo'); + assert.equal(vib.used, 'sin-datos', 'sin analisis web no hay juicio de uso'); + assert.equal(vib.rangeStatus, 'ok', 'sin aviso de rango'); + const special = g.getPermissionAudit(g.normalizeConfig({ appName: 'Lab Test', url: 'https://example.com', permissions: { systemAlert: true } })); + const overlay = special.items.find(i => i.key === 'systemAlert'); + assert.equal(overlay.granted, 'Manual en Settings', 'los especiales van por Settings'); +}); From 2682994979607e98144eebeed3557fb64e007047 Mon Sep 17 00:00:00 2001 From: Fluxionics Date: Fri, 9 Oct 2026 13:39:39 -0600 Subject: [PATCH 2/4] Badge del Pre-Build Doctor: PRO -> PREVIEW --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index ca9d61d..7a1c670 100644 --- a/index.html +++ b/index.html @@ -614,7 +614,7 @@

Preguntas frecuentes

- Pre-Build Doctor PRO + Pre-Build Doctor PREVIEW

Chequea config, permisos, HTML/URL y seguridad antes de compilar. Semáforo: OK, aviso o error por punto.

From 5c4030a865d62b4dac033797895b32789cf11b94 Mon Sep 17 00:00:00 2001 From: Fluxionics Date: Sat, 10 Oct 2026 09:37:12 -0600 Subject: [PATCH 3/4] =?UTF-8?q?banner=20de=20rese=C3=B1as=20cuando=20el=20?= =?UTF-8?q?build=20sale=20bien?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aparece una sola vez en el resultado, con link a GitHub y AlternativeTo. Si lo cierras ya no vuelve. Tests en 126 --- index.html | 6 ++++++ js/modules/build.js | 14 ++++++++++++++ package.json | 2 +- tests/review.test.js | 31 +++++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 tests/review.test.js diff --git a/index.html b/index.html index 7a1c670..a8d1746 100644 --- a/index.html +++ b/index.html @@ -1318,6 +1318,12 @@

Preguntas frecuentes

+ ', start)); + assert.ok(chunk.includes('https://github.com/Fluxionics/InteeBuild'), 'link de GitHub'); + assert.ok(chunk.includes('https://alternativeto.net/software/inteebuild/about/'), 'link de AlternativeTo'); + assert.ok(/target="_blank"/.test(chunk) && /rel="noopener"/.test(chunk), 'los links abren en pestaña nueva segura'); + assert.ok(chunk.includes('id="reviewClose"'), 'tiene como cerrarlo'); + assert.ok(!/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(chunk), 'sin emojis en el banner'); + assert.ok(chunk.includes('¿Te funcionó?'), 'texto natural, no de manual'); +}); + +test('review: se muestra cuando el build sale bien y respeta el cierre', () => { + assert.ok(buildJs.includes('maybeShowReviewBanner()'), 'se invoca en el bloque de exito'); + const successIdx = buildJs.indexOf("s.status === 'success'"); + const showIdx = buildJs.indexOf('maybeShowReviewBanner()'); + assert.ok(showIdx > successIdx, 'la llamada vive dentro del manejo de success'); + assert.ok(buildJs.includes("'ib_review_dismissed'"), 'guarda el cierre en localStorage'); + assert.ok(buildJs.includes('onAll(\'[data-review-dismiss]\''), 'los links tambien lo descartan'); +}); From 892b836d82fcf3324401d168bf705ebd160752e6 Mon Sep 17 00:00:00 2001 From: Fluxionics Date: Sat, 10 Oct 2026 10:53:28 -0600 Subject: [PATCH 4/4] guardados, monograma, ficha de Play, aviso de preview y mejoras al historial Mis proyectos en el navegador (guardar, cargar, exportar, importar). Icono y splash de monograma sin subir imagen. Ficha de Play Store con vista previa. El Doctor avisa permisos que Play suele rechazar. Historial con duracion, formatos a la vista, borrar todo y boton Cargar. Banner honesto de vista previa arriba de todo. Tests 133/133 --- index.html | 63 ++++++++++++++++--- js/app.js | 11 ++++ js/modules/build.js | 27 ++++++++- js/modules/form.js | 91 ++++++++++++++++++++++++++++ js/modules/projects.js | 107 +++++++++++++++++++++++++++++++++ package.json | 2 +- server/doctor.js | 20 ++++++ server/routes/system.js | 5 ++ tests/doctor.test.js | 12 ++++ tests/preview-features.test.js | 63 +++++++++++++++++++ 10 files changed, 388 insertions(+), 13 deletions(-) create mode 100644 js/modules/projects.js create mode 100644 tests/preview-features.test.js diff --git a/index.html b/index.html index a8d1746..dc37071 100644 --- a/index.html +++ b/index.html @@ -36,6 +36,12 @@ + +
@@ -155,15 +161,30 @@

Preguntas frecuentes

- + + +
+
+ + Mis proyectos +
+

Guarda la configuración con nombre para retomarla otro día. Queda en este navegador, sin cuenta ni registro. También puedes exportarla como archivo o importar una.

+
+ + + + +
+
+
@@ -324,6 +345,13 @@

Preguntas frecuentes

+
+ + + +
+