@danieltmn/tamnora 0.24.2 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/harness/packages/server/public/app.js +831 -834
- package/harness/packages/server/public/appearance.js +4 -0
- package/harness/packages/server/public/boot.js +49 -0
- package/harness/packages/server/public/chrome.js +853 -852
- package/harness/packages/server/public/commands.js +448 -405
- package/harness/packages/server/public/core.js +229 -216
- package/harness/packages/server/public/favicon.ico +0 -0
- package/harness/packages/server/public/icons/icon-128x128.png +0 -0
- package/harness/packages/server/public/icons/icon-144x144.png +0 -0
- package/harness/packages/server/public/icons/icon-152x152.png +0 -0
- package/harness/packages/server/public/icons/icon-192x192.png +0 -0
- package/harness/packages/server/public/icons/icon-384x384.png +0 -0
- package/harness/packages/server/public/icons/icon-512x512.png +0 -0
- package/harness/packages/server/public/icons/icon-72x72.png +0 -0
- package/harness/packages/server/public/icons/icon-96x96.png +0 -0
- package/harness/packages/server/public/icons/logo.png +0 -0
- package/harness/packages/server/public/icons/logo_dark.png +0 -0
- package/harness/packages/server/public/icons/logo_light.png +0 -0
- package/harness/packages/server/public/icons/tamnora.ico +0 -0
- package/harness/packages/server/public/index.html +72 -101
- package/harness/packages/server/public/landing/boot.js +16 -0
- package/harness/packages/server/public/landing/index.html +123 -59
- package/harness/packages/server/public/landing/landing.js +11 -9
- package/harness/packages/server/public/logo.png +0 -0
- package/harness/packages/server/public/markdown.js +1 -1
- package/harness/packages/server/public/settings.js +1155 -1080
- package/harness/packages/server/public/settingsNav.js +81 -0
- package/harness/packages/server/public/styles.css +2807 -2525
- package/harness/packages/server/public/system.js +2 -2
- package/harness/packages/server/src/lib/appearance.js +4 -0
- package/harness/packages/server/src/lib/restart.js +20 -0
- package/harness/packages/server/src/lib/securityHeaders.js +53 -0
- package/harness/packages/server/src/routes/reload.js +46 -0
- package/harness/packages/server/src/server.js +6 -1
- package/harness/packages/server/test/accessibility.test.js +72 -0
- package/harness/packages/server/test/appearance.test.js +4 -0
- package/harness/packages/server/test/consoleModules.test.js +133 -57
- package/harness/packages/server/test/consoleTabs.test.js +228 -0
- package/harness/packages/server/test/docsSync.test.js +73 -0
- package/harness/packages/server/test/landingLogin.test.js +89 -0
- package/harness/packages/server/test/markdownSecurity.test.js +80 -0
- package/harness/packages/server/test/reload.test.js +95 -0
- package/harness/packages/server/test/securityHeaders.test.js +109 -0
- package/harness/packages/server/test/settingsNav.test.js +90 -0
- package/harness/packages/server/test/themeCss.js +110 -0
- package/harness/packages/server/test/themes.test.js +72 -0
- package/harness/scripts/restart.mjs +101 -0
- package/package.json +1 -1
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Recorre la consola en un navegador de verdad: abre cada vista y cada panel de
|
|
2
|
+
// ajustes, y exige que (a) el click muestre lo que dice y (b) no haya errores de
|
|
3
|
+
// consola. Es la red de seguridad para tocar la UI sin verla: un panel que
|
|
4
|
+
// queda en blanco o un handler roto aparece aca y no en el uso diario.
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { findBrowserPath, captureScreenshot } from '../../core/src/browser/index.js';
|
|
8
|
+
import { startServer } from './helpers.js';
|
|
9
|
+
|
|
10
|
+
const HAY_NAVEGADOR = Boolean(findBrowserPath());
|
|
11
|
+
const RUIDO = /favicon|ERR_NAME_NOT_RESOLVED|ERR_INTERNET_DISCONNECTED|ERR_CONNECTION|ERR_NETWORK|Failed to load resource/i;
|
|
12
|
+
|
|
13
|
+
const RECORRIDO = `(async () => {
|
|
14
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
15
|
+
const visible = (node) => {
|
|
16
|
+
if (!node) return false;
|
|
17
|
+
const box = node.getBoundingClientRect();
|
|
18
|
+
return box.width > 0 && box.height > 0 && getComputedStyle(node).visibility !== 'hidden';
|
|
19
|
+
};
|
|
20
|
+
const out = { views: [], panels: [], overflow: [] };
|
|
21
|
+
|
|
22
|
+
for (const button of [...document.querySelectorAll('#more-menu button[data-tab]')]) {
|
|
23
|
+
const name = button.dataset.tab;
|
|
24
|
+
button.click();
|
|
25
|
+
await wait(250);
|
|
26
|
+
const view = document.getElementById('view-' + name);
|
|
27
|
+
out.views.push({ name, active: Boolean(view && view.classList.contains('active')), visible: visible(view) });
|
|
28
|
+
const wide = document.documentElement.scrollWidth - document.documentElement.clientWidth;
|
|
29
|
+
if (wide > 1) out.overflow.push({ where: 'view:' + name, px: wide });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
document.querySelector('#more-menu button[data-tab="ajustes"]')?.click();
|
|
33
|
+
await wait(250);
|
|
34
|
+
const triggers = [...document.querySelectorAll('.ajustes-tab')];
|
|
35
|
+
for (const trigger of triggers) {
|
|
36
|
+
const name = trigger.dataset.panel;
|
|
37
|
+
trigger.click();
|
|
38
|
+
await wait(200);
|
|
39
|
+
const panel = document.querySelector('.ajustes-panel[data-panel="' + name + '"]');
|
|
40
|
+
out.panels.push({ name, active: Boolean(panel && panel.classList.contains('active')), visible: visible(panel) });
|
|
41
|
+
const wide = document.documentElement.scrollWidth - document.documentElement.clientWidth;
|
|
42
|
+
if (wide > 1) out.overflow.push({ where: 'panel:' + name, px: wide });
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
})()`;
|
|
46
|
+
|
|
47
|
+
for (const [label, width, height] of [['escritorio', 1280, 800], ['tablet', 820, 1180], ['movil', 390, 844], ['movil angosto', 360, 740]]) {
|
|
48
|
+
test(
|
|
49
|
+
`consola (${label}): cada vista y cada panel de ajustes se muestra sin errores`,
|
|
50
|
+
{ skip: HAY_NAVEGADOR ? false : 'sin navegador headless', timeout: 90000 },
|
|
51
|
+
async () => {
|
|
52
|
+
const { instance, base } = await startServer();
|
|
53
|
+
try {
|
|
54
|
+
const shot = await captureScreenshot(base, {
|
|
55
|
+
allowPrivate: true,
|
|
56
|
+
width,
|
|
57
|
+
height,
|
|
58
|
+
waitMs: 300,
|
|
59
|
+
timeoutMs: 30000,
|
|
60
|
+
preScript: RECORRIDO,
|
|
61
|
+
});
|
|
62
|
+
const result = shot.preScriptResult;
|
|
63
|
+
assert.ok(result, 'el recorrido no devolvio resultado');
|
|
64
|
+
|
|
65
|
+
assert.ok(result.views.length >= 5, `vistas recorridas: ${JSON.stringify(result.views)}`);
|
|
66
|
+
for (const view of result.views) {
|
|
67
|
+
assert.ok(view.active && view.visible, `la vista "${view.name}" no se mostro (${label})`);
|
|
68
|
+
}
|
|
69
|
+
assert.ok(result.panels.length >= 5, `paneles recorridos: ${JSON.stringify(result.panels)}`);
|
|
70
|
+
for (const panel of result.panels) {
|
|
71
|
+
assert.ok(panel.active && panel.visible, `el panel "${panel.name}" no se mostro (${label})`);
|
|
72
|
+
}
|
|
73
|
+
assert.deepEqual(result.overflow, [], `scroll horizontal de pagina (${label})`);
|
|
74
|
+
|
|
75
|
+
const reales = shot.consoleErrors.filter((e) => !RUIDO.test(e.text));
|
|
76
|
+
assert.deepEqual(reales.map((e) => e.text), [], `errores de consola (${label})`);
|
|
77
|
+
} finally {
|
|
78
|
+
await instance.close();
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
const NAVEGACION = `(async () => {
|
|
86
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
87
|
+
const shown = (node) => {
|
|
88
|
+
if (!node || node.hidden) return false;
|
|
89
|
+
const box = node.getBoundingClientRect();
|
|
90
|
+
return box.width > 0 && box.height > 0;
|
|
91
|
+
};
|
|
92
|
+
const type = (input, value) => {
|
|
93
|
+
input.value = value;
|
|
94
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
95
|
+
};
|
|
96
|
+
const key = (node, k) => node.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
|
|
97
|
+
const visibles = () => [...document.querySelectorAll('.ajustes-tab')].filter((t) => !t.hidden).map((t) => t.dataset.panel);
|
|
98
|
+
const grupos = () => [...document.querySelectorAll('.ajustes-group')].filter((g) => !g.hidden).map((g) => g.dataset.group);
|
|
99
|
+
const out = { movil: window.innerWidth <= 820 };
|
|
100
|
+
|
|
101
|
+
document.querySelector('#more-menu button[data-tab="ajustes"]').click();
|
|
102
|
+
await wait(300);
|
|
103
|
+
const wrap = document.querySelector('.ajustes-wrap');
|
|
104
|
+
const search = document.getElementById('ajustes-search');
|
|
105
|
+
out.nivelInicial = wrap.dataset.level;
|
|
106
|
+
out.todas = visibles().length;
|
|
107
|
+
|
|
108
|
+
type(search, 'voz');
|
|
109
|
+
out.filtradoVoz = visibles();
|
|
110
|
+
out.gruposVoz = grupos();
|
|
111
|
+
type(search, 'tema');
|
|
112
|
+
out.filtradoTema = visibles();
|
|
113
|
+
type(search, 'zzzzzz');
|
|
114
|
+
out.sinResultados = { tabs: visibles().length, mensaje: shown(document.getElementById('ajustes-empty')), grupos: grupos().length };
|
|
115
|
+
type(search, 'limites');
|
|
116
|
+
key(search, 'Enter');
|
|
117
|
+
await wait(150);
|
|
118
|
+
out.enter = { activo: document.querySelector('.ajustes-tab.active')?.dataset.panel, selected: document.querySelector('.ajustes-tab[aria-selected="true"]')?.dataset.panel, nivel: wrap.dataset.level };
|
|
119
|
+
|
|
120
|
+
if (out.movil) {
|
|
121
|
+
out.movilDetalle = { lista: shown(document.querySelector('.ajustes-side')), volver: shown(document.getElementById('ajustes-back')) };
|
|
122
|
+
document.getElementById('ajustes-back').click();
|
|
123
|
+
await wait(100);
|
|
124
|
+
out.movilLista = { nivel: wrap.dataset.level, lista: shown(document.querySelector('.ajustes-side')), detalle: shown(document.querySelector('.ajustes-main')) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
type(search, '');
|
|
128
|
+
key(search, 'Escape');
|
|
129
|
+
type(search, 'zzz');
|
|
130
|
+
key(search, 'Escape');
|
|
131
|
+
out.escape = { valor: search.value, tabs: visibles().length };
|
|
132
|
+
|
|
133
|
+
document.getElementById('search-toggle').click();
|
|
134
|
+
await wait(200);
|
|
135
|
+
const palette = document.getElementById('palette-input');
|
|
136
|
+
type(palette, 'tema');
|
|
137
|
+
await wait(100);
|
|
138
|
+
const etiquetas = [...document.querySelectorAll('#palette-list .palette-item-label')].map((n) => n.textContent);
|
|
139
|
+
out.palette = etiquetas;
|
|
140
|
+
const item = [...document.querySelectorAll('#palette-list .palette-item')].find((n) => n.textContent.includes('Ajustes · Apariencia'));
|
|
141
|
+
item?.click();
|
|
142
|
+
await wait(400);
|
|
143
|
+
out.paletteSalto = {
|
|
144
|
+
vista: document.getElementById('view-ajustes').classList.contains('active'),
|
|
145
|
+
panel: document.querySelector('.ajustes-panel.active')?.dataset.panel,
|
|
146
|
+
nivel: wrap.dataset.level,
|
|
147
|
+
};
|
|
148
|
+
return out;
|
|
149
|
+
})()`;
|
|
150
|
+
|
|
151
|
+
for (const [label, width, height] of [['escritorio', 1280, 800], ['movil', 390, 844]]) {
|
|
152
|
+
test(
|
|
153
|
+
`ajustes (${label}): buscador, Enter, Escape, volver y palette`,
|
|
154
|
+
{ skip: HAY_NAVEGADOR ? false : 'sin navegador headless', timeout: 90000 },
|
|
155
|
+
async () => {
|
|
156
|
+
const { instance, base } = await startServer();
|
|
157
|
+
try {
|
|
158
|
+
const shot = await captureScreenshot(base, { allowPrivate: true, width, height, waitMs: 300, timeoutMs: 30000, preScript: NAVEGACION });
|
|
159
|
+
const r = shot.preScriptResult;
|
|
160
|
+
assert.ok(r, 'el script no devolvio resultado');
|
|
161
|
+
|
|
162
|
+
assert.equal(r.todas, 13, 'sin busqueda se ven las 13 secciones');
|
|
163
|
+
assert.deepEqual(r.filtradoVoz, ['voz']);
|
|
164
|
+
assert.deepEqual(r.gruposVoz, ['canales'], 'los grupos sin resultados se ocultan');
|
|
165
|
+
assert.ok(r.filtradoTema.includes('apariencia'), 'busca por palabra clave');
|
|
166
|
+
assert.deepEqual(r.sinResultados, { tabs: 0, mensaje: true, grupos: 0 });
|
|
167
|
+
assert.deepEqual(r.enter, { activo: 'limites', selected: 'limites', nivel: 'panel' });
|
|
168
|
+
assert.deepEqual(r.escape, { valor: '', tabs: 13 }, 'Escape limpia la busqueda y vuelve a mostrar todo');
|
|
169
|
+
assert.ok(r.palette.some((label) => label === 'Ajustes · Apariencia'), `la palette no encontro Apariencia: ${r.palette}`);
|
|
170
|
+
assert.deepEqual(r.paletteSalto, { vista: true, panel: 'apariencia', nivel: 'panel' });
|
|
171
|
+
|
|
172
|
+
if (r.movil) {
|
|
173
|
+
assert.equal(r.nivelInicial, 'list', 'en movil se abre en la lista');
|
|
174
|
+
assert.deepEqual(r.movilDetalle, { lista: false, volver: true }, 'en el detalle se oculta la lista y aparece volver');
|
|
175
|
+
assert.deepEqual(r.movilLista, { nivel: 'list', lista: true, detalle: false }, 'volver regresa a la lista');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const reales = shot.consoleErrors.filter((e) => !RUIDO.test(e.text));
|
|
179
|
+
assert.deepEqual(reales.map((e) => e.text), [], `errores de consola (${label})`);
|
|
180
|
+
} finally {
|
|
181
|
+
await instance.close();
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
const TAMANOS = `(async () => {
|
|
189
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
190
|
+
await wait(1200);
|
|
191
|
+
const chicos = [];
|
|
192
|
+
const scan = (donde) => {
|
|
193
|
+
for (const node of document.querySelectorAll('button, a[href], input:not([type=hidden]), select, textarea, [role=tab], [role=menuitem], summary')) {
|
|
194
|
+
const box = node.getBoundingClientRect();
|
|
195
|
+
if (box.width < 1 || box.height < 1 || getComputedStyle(node).visibility === 'hidden') continue;
|
|
196
|
+
if (node.type === 'checkbox' || node.type === 'radio' || node.type === 'range') continue;
|
|
197
|
+
if (box.width < 28 || box.height < 28) chicos.push(donde + ' ' + (node.id ? '#' + node.id : node.tagName.toLowerCase() + '.' + String(node.className).split(' ')[0]) + ' ' + Math.round(box.width) + 'x' + Math.round(box.height));
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
scan('chat');
|
|
201
|
+
for (const tab of ['providers', 'ajustes', 'eval', 'runs']) {
|
|
202
|
+
document.querySelector('#more-menu button[data-tab="' + tab + '"]').click();
|
|
203
|
+
await wait(250);
|
|
204
|
+
scan(tab);
|
|
205
|
+
}
|
|
206
|
+
for (const trigger of document.querySelectorAll('.ajustes-tab')) {
|
|
207
|
+
trigger.click();
|
|
208
|
+
await wait(120);
|
|
209
|
+
scan('ajustes:' + trigger.dataset.panel);
|
|
210
|
+
}
|
|
211
|
+
return chicos;
|
|
212
|
+
})()`;
|
|
213
|
+
|
|
214
|
+
for (const [label, width, height] of [['movil', 390, 844], ['movil angosto', 360, 740]]) {
|
|
215
|
+
test(
|
|
216
|
+
`controles tactiles (${label}): ninguno mide menos de 28 px`,
|
|
217
|
+
{ skip: HAY_NAVEGADOR ? false : 'sin navegador headless', timeout: 90000 },
|
|
218
|
+
async () => {
|
|
219
|
+
const { instance, base } = await startServer();
|
|
220
|
+
try {
|
|
221
|
+
const shot = await captureScreenshot(base, { allowPrivate: true, width, height, waitMs: 200, timeoutMs: 30000, preScript: TAMANOS });
|
|
222
|
+
assert.deepEqual(shot.preScriptResult, [], `controles demasiado chicos (${label})`);
|
|
223
|
+
} finally {
|
|
224
|
+
await instance.close();
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// La tabla de endpoints de `AGENTS.md` contra las rutas que registra el server.
|
|
2
|
+
//
|
|
3
|
+
// La doc vive en un archivo solo (AGENTS.md) y se lee como un texto mas: si se
|
|
4
|
+
// agrega una ruta y no se documenta (o la doc menciona una ruta que ya no
|
|
5
|
+
// existe), esto lo detecta sin tener que acordarse de mirar. Compara rutas, no
|
|
6
|
+
// metodos: la tabla usa abreviaturas (`GET/PUT /api/settings`) y no hace falta
|
|
7
|
+
// que enumere cada combinacion.
|
|
8
|
+
import { test } from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const TEST_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const SRC_DIR = path.join(TEST_DIR, '..', 'src');
|
|
16
|
+
|
|
17
|
+
function findAgentsMd() {
|
|
18
|
+
let dir = TEST_DIR;
|
|
19
|
+
for (let i = 0; i < 8; i += 1) {
|
|
20
|
+
const candidate = path.join(dir, 'AGENTS.md');
|
|
21
|
+
if (existsSync(candidate)) return candidate;
|
|
22
|
+
dir = path.dirname(dir);
|
|
23
|
+
}
|
|
24
|
+
throw new Error('no encontre AGENTS.md subiendo desde test/');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const AGENTS_MD = findAgentsMd();
|
|
28
|
+
|
|
29
|
+
/** Rutas `/api/...` que registra el server (`route('METHOD', '/api/...')`). */
|
|
30
|
+
function codeRoutes() {
|
|
31
|
+
const files = [path.join(SRC_DIR, 'server.js'), ...readdirSync(path.join(SRC_DIR, 'routes')).map((f) => path.join(SRC_DIR, 'routes', f))];
|
|
32
|
+
const routes = new Set();
|
|
33
|
+
for (const file of files) {
|
|
34
|
+
const text = readFileSync(file, 'utf8');
|
|
35
|
+
for (const m of text.matchAll(/route\('(GET|POST|PUT|DELETE|PATCH)',\s*'(\/api\/[^']*)'/g)) routes.add(m[2]);
|
|
36
|
+
}
|
|
37
|
+
return routes;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Paths `/api/...` que menciona AGENTS.md (tabla y prosa). */
|
|
41
|
+
function docPaths() {
|
|
42
|
+
const text = readFileSync(AGENTS_MD, 'utf8');
|
|
43
|
+
const paths = new Set();
|
|
44
|
+
for (const m of text.matchAll(/\/api\/[A-Za-z0-9_\-/:.]+/g)) {
|
|
45
|
+
const p = m[0].replace(/[.,;:]+$/, '');
|
|
46
|
+
if (/^\/api\/?$/.test(p)) continue; // menciones comodin ("/api/..."), no rutas
|
|
47
|
+
paths.add(p);
|
|
48
|
+
}
|
|
49
|
+
return paths;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `/api/sessions/:id` y `/api/sessions/:name` son la misma ruta. */
|
|
53
|
+
const norm = (p) => p.replace(/\/:[A-Za-z]+/g, '/:p');
|
|
54
|
+
|
|
55
|
+
const code = codeRoutes();
|
|
56
|
+
const doc = docPaths();
|
|
57
|
+
const codeByNorm = new Map([...code].map((p) => [norm(p), p]));
|
|
58
|
+
const docByNorm = new Map([...doc].map((p) => [norm(p), p]));
|
|
59
|
+
|
|
60
|
+
test('hay rutas registradas para comparar (el parser no se rompio)', () => {
|
|
61
|
+
assert.ok(code.size > 60, `solo se leyeron ${code.size} rutas del server`);
|
|
62
|
+
assert.ok(doc.size > 60, `solo se leyeron ${doc.size} paths de AGENTS.md`);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('toda ruta del server esta documentada en AGENTS.md', () => {
|
|
66
|
+
const faltan = [...codeByNorm].filter(([n]) => !docByNorm.has(n)).map(([, p]) => p);
|
|
67
|
+
assert.deepEqual(faltan, [], `rutas sin documentar en AGENTS.md: ${faltan.join(', ')}`);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('todo /api/... que menciona AGENTS.md existe en el server', () => {
|
|
71
|
+
const sobran = [...docByNorm].filter(([n]) => !codeByNorm.has(n)).map(([, p]) => p);
|
|
72
|
+
assert.deepEqual(sobran, [], `paths de AGENTS.md que ya no existen: ${sobran.join(', ')}`);
|
|
73
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Regresion del login de la landing en un navegador de verdad.
|
|
2
|
+
//
|
|
3
|
+
// La landing se construye sobre elementos del DOM que pueden no existir (el
|
|
4
|
+
// boton de tema se quito del header): si un `getElementById` devuelve null y se
|
|
5
|
+
// usa sin guarda, el modulo entero se cae con un TypeError antes de registrar
|
|
6
|
+
// el handler del form y el login queda "muerto" (el submit nativo recarga la
|
|
7
|
+
// pagina y no pasa nada). Este test abre /landing con auth configurada, dispara
|
|
8
|
+
// el submit y exige que el handler llame a /api/auth/login, sin errores de
|
|
9
|
+
// consola.
|
|
10
|
+
import { test } from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { findBrowserPath, captureScreenshot } from '../../core/src/browser/index.js';
|
|
16
|
+
import { setHome, ensureApp, writeApp, makeUser } from '../../core/src/index.js';
|
|
17
|
+
import { createTamnoraServer } from '../src/index.js';
|
|
18
|
+
import { fakeProvider } from './helpers.js';
|
|
19
|
+
|
|
20
|
+
const HAY_NAVEGADOR = Boolean(findBrowserPath());
|
|
21
|
+
const RUIDO = /favicon|ERR_NAME_NOT_RESOLVED|ERR_INTERNET_DISCONNECTED|ERR_CONNECTION|ERR_NETWORK|Failed to load resource/i;
|
|
22
|
+
|
|
23
|
+
// Intercepta fetch para no depender del server aca y para que el submit no
|
|
24
|
+
// navegue a la consola a mitad del script. El listener en fase de captura solo
|
|
25
|
+
// cancela la accion por defecto (la recarga), no a los otros listeners.
|
|
26
|
+
const LOGIN = `(async () => {
|
|
27
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
28
|
+
const form = document.getElementById('login-form');
|
|
29
|
+
const out = { form: Boolean(form), user: Boolean(document.getElementById('user')), password: Boolean(document.getElementById('password')), calls: [] };
|
|
30
|
+
if (!form) return out;
|
|
31
|
+
await wait(300);
|
|
32
|
+
const realFetch = window.fetch;
|
|
33
|
+
window.fetch = (url, opts) => {
|
|
34
|
+
out.calls.push({ url: String(url), method: opts?.method || 'GET', body: opts?.body || '' });
|
|
35
|
+
// 401 controlado: el handler responde con el toast y NO navega a la
|
|
36
|
+
// consola, que romperia la captura a mitad del script.
|
|
37
|
+
return Promise.resolve(new Response(JSON.stringify({ error: 'prueba' }), { status: 401, headers: { 'Content-Type': 'application/json' } }));
|
|
38
|
+
};
|
|
39
|
+
form.addEventListener('submit', (e) => e.preventDefault(), { capture: true });
|
|
40
|
+
document.getElementById('user').value = 'admin';
|
|
41
|
+
document.getElementById('password').value = 'secreto123';
|
|
42
|
+
form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
|
43
|
+
await wait(300);
|
|
44
|
+
window.fetch = realFetch;
|
|
45
|
+
return out;
|
|
46
|
+
})()`;
|
|
47
|
+
|
|
48
|
+
test(
|
|
49
|
+
'la landing loguea: el submit del form llama a /api/auth/login sin romperse',
|
|
50
|
+
{ skip: HAY_NAVEGADOR ? false : 'sin navegador headless', timeout: 90000 },
|
|
51
|
+
async () => {
|
|
52
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-landing-'));
|
|
53
|
+
setHome(cwd);
|
|
54
|
+
const app = ensureApp();
|
|
55
|
+
app.user = makeUser('admin', 'secreto123');
|
|
56
|
+
writeApp(app);
|
|
57
|
+
|
|
58
|
+
const instance = await createTamnoraServer({
|
|
59
|
+
cwd,
|
|
60
|
+
dbPath: path.join(cwd, 'tamnora.db'),
|
|
61
|
+
loadMcp: false,
|
|
62
|
+
resolveProvider: () => ({ provider: fakeProvider, config: {} }),
|
|
63
|
+
});
|
|
64
|
+
const info = await instance.listen({ port: 0 });
|
|
65
|
+
try {
|
|
66
|
+
const shot = await captureScreenshot(`${info.url}/landing`, {
|
|
67
|
+
allowPrivate: true,
|
|
68
|
+
width: 1280,
|
|
69
|
+
height: 800,
|
|
70
|
+
waitMs: 300,
|
|
71
|
+
timeoutMs: 30000,
|
|
72
|
+
preScript: LOGIN,
|
|
73
|
+
});
|
|
74
|
+
const result = shot.preScriptResult;
|
|
75
|
+
assert.ok(result, 'el script de la landing no devolvio resultado');
|
|
76
|
+
assert.ok(result.form && result.user && result.password, 'faltan el form o los inputs de login');
|
|
77
|
+
|
|
78
|
+
const login = result.calls.find((c) => c.url.endsWith('/api/auth/login') && c.method === 'POST');
|
|
79
|
+
assert.ok(login, `el submit del form no llamo a /api/auth/login (llamadas: ${JSON.stringify(result.calls)})`);
|
|
80
|
+
const body = JSON.parse(login.body);
|
|
81
|
+
assert.deepEqual(body, { username: 'admin', password: 'secreto123' });
|
|
82
|
+
|
|
83
|
+
const reales = shot.consoleErrors.filter((e) => !RUIDO.test(e.text));
|
|
84
|
+
assert.deepEqual(reales.map((e) => e.text), [], 'la landing tiro errores al cargar');
|
|
85
|
+
} finally {
|
|
86
|
+
await instance.close();
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Regresion de XSS del renderer de markdown. La consola mete su salida con
|
|
2
|
+
// `innerHTML`, asi que cualquier etiqueta o atributo que se cuele con texto del
|
|
3
|
+
// modelo (o de una pagina que el modelo leyo) se ejecutaria en la consola.
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { renderMarkdown, highlight } from '../public/markdown.js';
|
|
7
|
+
|
|
8
|
+
/** Etiquetas HTML reales que quedaron en el resultado (no las escapadas). */
|
|
9
|
+
function tags(html) {
|
|
10
|
+
return [...html.matchAll(/<\/?([a-z][a-z0-9]*)\b[^>]*>/gi)].map((m) => m[1].toLowerCase());
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const PERMITIDAS = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'em', 'code', 'a', 'ul', 'ol', 'li', 'hr', 'blockquote', 'pre', 'div', 'span', 'button', 'table', 'thead', 'tbody', 'tr', 'th', 'td']);
|
|
14
|
+
|
|
15
|
+
const PAYLOADS = [
|
|
16
|
+
'<img src=x onerror=alert(1)>',
|
|
17
|
+
'<svg/onload=alert(1)>',
|
|
18
|
+
'<script>alert(1)</script>',
|
|
19
|
+
'<iframe src="javascript:alert(1)"></iframe>',
|
|
20
|
+
'<a href="javascript:alert(1)">x</a>',
|
|
21
|
+
'"><img src=x onerror=alert(1)>',
|
|
22
|
+
"'><script>alert(1)</script>",
|
|
23
|
+
'</p><script>alert(1)</script>',
|
|
24
|
+
'<details open ontoggle=alert(1)>',
|
|
25
|
+
'<style>*{display:none}</style>',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
test('ningun payload clasico deja etiquetas fuera de la lista permitida', () => {
|
|
29
|
+
for (const payload of PAYLOADS) {
|
|
30
|
+
for (const wrap of [(p) => p, (p) => `# ${p}`, (p) => `- ${p}`, (p) => `> ${p}`, (p) => `**${p}**`, (p) => `| ${p} |\n| --- |\n| ${p} |`]) {
|
|
31
|
+
const html = renderMarkdown(wrap(payload));
|
|
32
|
+
for (const tag of tags(html)) {
|
|
33
|
+
assert.ok(PERMITIDAS.has(tag), `etiqueta <${tag}> inesperada con ${JSON.stringify(payload)} -> ${html}`);
|
|
34
|
+
}
|
|
35
|
+
assert.ok(!/<(script|img|svg|iframe|style|details)\b/i.test(html), `etiqueta peligrosa viva: ${html}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('un payload dentro de un bloque de codigo y su lenguaje queda escapado', () => {
|
|
41
|
+
for (const lang of ['js', 'html', 'css']) {
|
|
42
|
+
const html = renderMarkdown('```' + lang + '\n<img src=x onerror=alert(1)><script>alert(1)</script>\n```');
|
|
43
|
+
assert.ok(!/<img\b/i.test(html), html);
|
|
44
|
+
assert.ok(!/<script\b/i.test(html), html);
|
|
45
|
+
assert.match(html, /</);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('los enlaces solo salen para http(s) y no se pueden salir del atributo href', () => {
|
|
50
|
+
for (const url of ['javascript:alert(1)', 'JaVaScRiPt:alert(1)', 'data:text/html,<script>alert(1)</script>', 'vbscript:x', ' javascript:alert(1)']) {
|
|
51
|
+
const html = renderMarkdown(`[click](${url})`);
|
|
52
|
+
assert.ok(!/<a\b/i.test(html), `no deberia haber enlace para ${url}: ${html}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const html = renderMarkdown('[x](https://a.com/"onmouseover="alert(1))');
|
|
56
|
+
assert.ok(!/\sonmouseover\s*=/i.test(html), `el atributo se escapo del href: ${html}`);
|
|
57
|
+
const anchors = html.match(/<a\b[^>]*>/g) ?? [];
|
|
58
|
+
for (const anchor of anchors) {
|
|
59
|
+
const attrs = [...anchor.matchAll(/\s([a-z-]+)=/gi)].map((m) => m[1]);
|
|
60
|
+
assert.deepEqual(attrs.sort(), ['href', 'rel', 'target'], anchor);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('el nombre del lenguaje del fence no se interpreta como HTML', () => {
|
|
65
|
+
const html = renderMarkdown('```<img src=x onerror=alert(1)>\ncodigo\n```');
|
|
66
|
+
assert.ok(!/<img\b/i.test(html), html);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('los marcadores internos del inline no se pueden forjar desde el texto', () => {
|
|
70
|
+
const html = renderMarkdown('texto \uE0005\uE000 y `real`');
|
|
71
|
+
assert.ok(!/undefined/.test(html), html);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('highlight de html/css/js nunca devuelve etiquetas vivas', () => {
|
|
75
|
+
for (const lang of ['html', 'xml', 'css', 'js', 'python', '']) {
|
|
76
|
+
const out = highlight('<img src=x onerror=alert(1)> "</span><script>alert(1)</script>"', lang);
|
|
77
|
+
assert.ok(!/<(img|script)\b/i.test(out), `${lang}: ${out}`);
|
|
78
|
+
for (const tag of tags(out)) assert.equal(tag, 'span', `${lang}: ${out}`);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Recargas operativas desde la consola: reiniciar el server y abrir el puente.
|
|
2
|
+
// El spawn y tailscale se inyectan (como `spawnUpdater` en el update) para no
|
|
3
|
+
// matar el proceso de test ni depender de tener tailscale instalado.
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { createTamnoraServer } from '../src/index.js';
|
|
10
|
+
import { setHome } from '../../core/src/index.js';
|
|
11
|
+
|
|
12
|
+
async function startServer() {
|
|
13
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-reload-'));
|
|
14
|
+
setHome(cwd);
|
|
15
|
+
const instance = await createTamnoraServer({
|
|
16
|
+
cwd,
|
|
17
|
+
dbPath: path.join(cwd, 'tamnora.db'),
|
|
18
|
+
loadMcp: false,
|
|
19
|
+
resolveProvider: () => ({ provider: { id: 'fake' }, config: {} }),
|
|
20
|
+
});
|
|
21
|
+
const info = await instance.listen({ port: 0 });
|
|
22
|
+
return { instance, base: info.url };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('POST /api/server/restart dispara el relanzador y responde 202', async () => {
|
|
26
|
+
const { instance, base } = await startServer();
|
|
27
|
+
let called = null;
|
|
28
|
+
instance.ctx.spawnRestarter = (options) => {
|
|
29
|
+
called = options;
|
|
30
|
+
return { ok: true, pid: 1234 };
|
|
31
|
+
};
|
|
32
|
+
instance.ctx.shutdown = async () => {};
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch(`${base}/api/server/restart`, { method: 'POST' });
|
|
35
|
+
assert.equal(res.status, 202);
|
|
36
|
+
const data = await res.json();
|
|
37
|
+
assert.equal(data.restarting, true);
|
|
38
|
+
assert.ok(called);
|
|
39
|
+
assert.equal(called.pid, process.pid);
|
|
40
|
+
assert.ok(Array.isArray(called.argv) && called.argv.length > 0, 'el relanzador necesita el argv del server');
|
|
41
|
+
} finally {
|
|
42
|
+
await instance.close();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('POST /api/server/restart falla claro si no puede lanzar el relanzador', async () => {
|
|
47
|
+
const { instance, base } = await startServer();
|
|
48
|
+
instance.ctx.spawnRestarter = () => ({ ok: false, error: 'no arranco' });
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(`${base}/api/server/restart`, { method: 'POST' });
|
|
51
|
+
assert.equal(res.status, 500);
|
|
52
|
+
const data = await res.json();
|
|
53
|
+
assert.match(data.error, /no arranco/);
|
|
54
|
+
} finally {
|
|
55
|
+
await instance.close();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('POST /api/bridge/reload reinicia el puente y devuelve la URL', async () => {
|
|
60
|
+
const { instance, base } = await startServer();
|
|
61
|
+
const eventos = [];
|
|
62
|
+
instance.ctx.tunnelStop = async () => {
|
|
63
|
+
eventos.push('stop');
|
|
64
|
+
};
|
|
65
|
+
instance.ctx.tunnelStart = async (port) => {
|
|
66
|
+
eventos.push(`start:${port}`);
|
|
67
|
+
return { url: 'https://demo.ts.net', provider: 'tailscale' };
|
|
68
|
+
};
|
|
69
|
+
try {
|
|
70
|
+
const res = await fetch(`${base}/api/bridge/reload`, { method: 'POST' });
|
|
71
|
+
assert.equal(res.status, 200);
|
|
72
|
+
const data = await res.json();
|
|
73
|
+
assert.equal(data.ok, true);
|
|
74
|
+
assert.equal(data.url, 'https://demo.ts.net');
|
|
75
|
+
assert.deepEqual(eventos, ['stop', `start:${instance.ctx.serverInfo.port}`], 'apaga y vuelve a publicar el puerto del server');
|
|
76
|
+
} finally {
|
|
77
|
+
await instance.close();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('POST /api/bridge/reload expone el fallo de tailscale como 502', async () => {
|
|
82
|
+
const { instance, base } = await startServer();
|
|
83
|
+
instance.ctx.tunnelStop = async () => {};
|
|
84
|
+
instance.ctx.tunnelStart = async () => {
|
|
85
|
+
throw new Error('Tailscale no esta conectado.');
|
|
86
|
+
};
|
|
87
|
+
try {
|
|
88
|
+
const res = await fetch(`${base}/api/bridge/reload`, { method: 'POST' });
|
|
89
|
+
assert.equal(res.status, 502);
|
|
90
|
+
const data = await res.json();
|
|
91
|
+
assert.match(data.error, /Tailscale/);
|
|
92
|
+
} finally {
|
|
93
|
+
await instance.close();
|
|
94
|
+
}
|
|
95
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, writeFile, readFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { startServer } from './helpers.js';
|
|
7
|
+
import { buildCsp, CSP_DIRECTIVES, cspMode } from '../src/lib/securityHeaders.js';
|
|
8
|
+
|
|
9
|
+
const PUBLIC = new URL('../public/', import.meta.url);
|
|
10
|
+
|
|
11
|
+
async function serverWithConfig(config) {
|
|
12
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-csp-'));
|
|
13
|
+
if (config) await writeFile(path.join(cwd, 'tamnora.json'), JSON.stringify(config));
|
|
14
|
+
return startServer({ cwd });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test('la consola sale con CSP estricta y cabeceras de endurecimiento', async () => {
|
|
18
|
+
const { instance, base } = await startServer();
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch(`${base}/`);
|
|
21
|
+
assert.equal(res.status, 200);
|
|
22
|
+
const csp = res.headers.get('content-security-policy');
|
|
23
|
+
assert.ok(csp, 'falta Content-Security-Policy');
|
|
24
|
+
assert.match(csp, /(^|;\s*)script-src 'self'(;|$)/);
|
|
25
|
+
assert.ok(!/script-src[^;]*unsafe-(inline|eval)/.test(csp), 'script-src no puede permitir inline ni eval');
|
|
26
|
+
assert.match(csp, /frame-ancestors 'self'/);
|
|
27
|
+
assert.match(csp, /object-src 'none'/);
|
|
28
|
+
assert.match(csp, /base-uri 'self'/);
|
|
29
|
+
assert.equal(res.headers.get('x-content-type-options'), 'nosniff');
|
|
30
|
+
assert.equal(res.headers.get('x-frame-options'), 'SAMEORIGIN');
|
|
31
|
+
assert.equal(res.headers.get('referrer-policy'), 'same-origin');
|
|
32
|
+
} finally {
|
|
33
|
+
await instance.close();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('la CSP deja pasar lo que la consola necesita: preview embebido y fuentes', () => {
|
|
38
|
+
const csp = buildCsp();
|
|
39
|
+
assert.match(csp, /frame-src http: https:/);
|
|
40
|
+
assert.match(csp, /style-src [^;]*https:\/\/fonts\.googleapis\.com/);
|
|
41
|
+
assert.match(csp, /font-src [^;]*https:\/\/fonts\.gstatic\.com/);
|
|
42
|
+
assert.match(csp, /img-src [^;]*data: blob:/);
|
|
43
|
+
assert.match(csp, /media-src [^;]*blob:/);
|
|
44
|
+
assert.match(csp, /connect-src 'self'(;|$)/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('la API lleva nosniff pero no CSP (no protege nada en un JSON)', async () => {
|
|
48
|
+
const { instance, base } = await startServer();
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(`${base}/api/health`);
|
|
51
|
+
assert.equal(res.status, 200);
|
|
52
|
+
assert.equal(res.headers.get('x-content-type-options'), 'nosniff');
|
|
53
|
+
assert.equal(res.headers.get('content-security-policy'), null);
|
|
54
|
+
} finally {
|
|
55
|
+
await instance.close();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('el landing tambien lleva CSP', async () => {
|
|
60
|
+
const { instance, base } = await startServer();
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetch(`${base}/landing/`);
|
|
63
|
+
assert.equal(res.status, 200);
|
|
64
|
+
assert.ok(res.headers.get('content-security-policy'));
|
|
65
|
+
} finally {
|
|
66
|
+
await instance.close();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('server.csp permite pasar a report-only o apagarla', async () => {
|
|
71
|
+
for (const [mode, header, present] of [
|
|
72
|
+
['report-only', 'content-security-policy-report-only', true],
|
|
73
|
+
['off', 'content-security-policy', false],
|
|
74
|
+
]) {
|
|
75
|
+
const { instance, base } = await serverWithConfig({ server: { csp: mode } });
|
|
76
|
+
try {
|
|
77
|
+
const res = await fetch(`${base}/`);
|
|
78
|
+
assert.equal(Boolean(res.headers.get(header)), present, mode);
|
|
79
|
+
if (mode === 'report-only') assert.equal(res.headers.get('content-security-policy'), null);
|
|
80
|
+
assert.equal(res.headers.get('x-content-type-options'), 'nosniff', `${mode}: nosniff sigue`);
|
|
81
|
+
} finally {
|
|
82
|
+
await instance.close();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('cspMode ignora valores invalidos y cae a enforce', () => {
|
|
88
|
+
assert.equal(cspMode({}), 'enforce');
|
|
89
|
+
assert.equal(cspMode({ server: { csp: 'banana' } }), 'enforce');
|
|
90
|
+
assert.equal(cspMode({ server: { csp: 'off' } }), 'off');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('ningun directive de la CSP queda vacio', () => {
|
|
94
|
+
for (const [name, sources] of Object.entries(CSP_DIRECTIVES)) {
|
|
95
|
+
assert.ok(sources.length > 0, name);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('el HTML de la consola no tiene scripts ni handlers inline (la CSP los bloquearia)', async () => {
|
|
100
|
+
for (const file of ['index.html', 'landing/index.html']) {
|
|
101
|
+
const html = await readFile(new URL(file, PUBLIC), 'utf8');
|
|
102
|
+
const scripts = [...html.matchAll(/<script\b([^>]*)>/gi)];
|
|
103
|
+
assert.ok(scripts.length > 0, `${file}: sin scripts?`);
|
|
104
|
+
for (const [, attrs] of scripts) {
|
|
105
|
+
assert.match(attrs, /\ssrc=/, `${file}: script inline`);
|
|
106
|
+
}
|
|
107
|
+
assert.ok(!/\son[a-z]+\s*=\s*["']/i.test(html), `${file}: handler inline (onclick=...)`);
|
|
108
|
+
}
|
|
109
|
+
});
|