@danieltmn/tamnora 0.24.2 → 0.25.1
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 +20 -0
- package/harness/packages/cli/src/commands/repl.js +18 -2
- package/harness/packages/cli/src/commands/run.js +5 -1
- package/harness/packages/cli/src/commands/tui.js +5 -1
- package/harness/packages/cli/src/lib/runtime.js +2 -0
- package/harness/packages/cli/src/tui/app.js +22 -2
- package/harness/packages/cli/src/tui/layout.js +1 -1
- package/harness/packages/core/src/agent/runAgent.js +5 -2
- package/harness/packages/core/src/agents/index.js +141 -0
- package/harness/packages/core/src/config/index.js +1 -0
- package/harness/packages/core/src/index.js +10 -0
- package/harness/packages/core/src/providers/index.js +3 -1
- package/harness/packages/core/src/providers/ollama.js +4 -1
- package/harness/packages/core/src/providers/openaiCompatible.js +6 -1
- package/harness/packages/core/src/providers/openrouter.js +5 -1
- package/harness/packages/core/src/providers/presets.js +4 -3
- package/harness/packages/core/src/providers/reasoning.js +58 -0
- package/harness/packages/core/test/agents.test.js +111 -0
- package/harness/packages/core/test/reasoning.test.js +43 -0
- package/harness/packages/server/public/app.js +868 -834
- package/harness/packages/server/public/appearance.js +4 -0
- package/harness/packages/server/public/boot.js +49 -0
- package/harness/packages/server/public/chat.js +1 -0
- package/harness/packages/server/public/chrome.js +946 -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/icons (1).zip +0 -0
- package/harness/packages/server/public/index.html +161 -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/sessions.js +1 -0
- package/harness/packages/server/public/settings.js +1322 -1080
- package/harness/packages/server/public/settingsNav.js +82 -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/agents.js +74 -0
- package/harness/packages/server/src/lib/appearance.js +4 -0
- package/harness/packages/server/src/lib/channels/telegram/run.js +1 -0
- package/harness/packages/server/src/lib/prompt.js +11 -3
- package/harness/packages/server/src/lib/restart.js +20 -0
- package/harness/packages/server/src/lib/scheduler.js +4 -0
- package/harness/packages/server/src/lib/securityHeaders.js +53 -0
- package/harness/packages/server/src/lib/sessionView.js +1 -0
- package/harness/packages/server/src/lib/skills.js +13 -0
- package/harness/packages/server/src/lib/tasks.js +71 -23
- package/harness/packages/server/src/routes/agents.js +88 -0
- package/harness/packages/server/src/routes/chat.js +9 -1
- package/harness/packages/server/src/routes/providers.js +7 -0
- package/harness/packages/server/src/routes/reload.js +46 -0
- package/harness/packages/server/src/routes/sessions.js +3 -1
- package/harness/packages/server/src/server.js +13 -1
- package/harness/packages/server/test/accessibility.test.js +72 -0
- package/harness/packages/server/test/agents.test.js +194 -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/harness/tamnora.example.json +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { SETTINGS_GROUPS, SETTINGS_SECTIONS, filterSettings, foldText, hasSettingsSection } from '../public/settingsNav.js';
|
|
5
|
+
|
|
6
|
+
const html = readFileSync(new URL('../public/index.html', import.meta.url), 'utf8');
|
|
7
|
+
|
|
8
|
+
const ids = (list) => list.map((section) => section.id);
|
|
9
|
+
|
|
10
|
+
test('foldText ignora mayusculas y acentos', () => {
|
|
11
|
+
assert.equal(foldText(' Límites '), 'limites');
|
|
12
|
+
assert.equal(foldText('Transcripción'), 'transcripcion');
|
|
13
|
+
assert.equal(foldText(null), '');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('sin consulta devuelve todas las secciones, en el orden de los grupos', () => {
|
|
17
|
+
assert.deepEqual(ids(filterSettings('')), ids(SETTINGS_SECTIONS));
|
|
18
|
+
assert.deepEqual(ids(filterSettings(' ')), ids(SETTINGS_SECTIONS));
|
|
19
|
+
assert.equal(SETTINGS_SECTIONS.length, 14);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('busca por nombre sin importar acentos ni mayusculas', () => {
|
|
23
|
+
assert.deepEqual(ids(filterSettings('limites')), ['limites']);
|
|
24
|
+
assert.deepEqual(ids(filterSettings('TRANSCRIPCIÓN')), ['transcripcion']);
|
|
25
|
+
assert.deepEqual(ids(filterSettings('tele')), ['telegram']);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('el nombre del grupo no tapa a la seccion: "voz" da Voz, no todo "Voz y canales"', () => {
|
|
29
|
+
assert.deepEqual(ids(filterSettings('voz')), ['voz']);
|
|
30
|
+
assert.deepEqual(ids(filterSettings('canales')), ['voz', 'transcripcion', 'asesor', 'telegram']);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('busca por palabra clave y por grupo', () => {
|
|
34
|
+
assert.ok(ids(filterSettings('tema')).includes('apariencia'));
|
|
35
|
+
assert.ok(ids(filterSettings('dictado')).includes('transcripcion'));
|
|
36
|
+
assert.ok(ids(filterSettings('cron')).includes('tareas'));
|
|
37
|
+
assert.deepEqual(ids(filterSettings('contexto')), ['perfil', 'memoria', 'skills', 'agentes', 'mcp']);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('todas las palabras de la consulta tienen que coincidir', () => {
|
|
41
|
+
assert.deepEqual(ids(filterSettings('voz hablar')), ['voz']);
|
|
42
|
+
assert.deepEqual(ids(filterSettings('voz zzzz')), []);
|
|
43
|
+
assert.deepEqual(ids(filterSettings('nada que ver')), []);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('las coincidencias por nombre van antes que las de palabra clave', () => {
|
|
47
|
+
const result = ids(filterSettings('mcp'));
|
|
48
|
+
assert.equal(result[0], 'mcp');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('hasSettingsSection distingue ids reales', () => {
|
|
52
|
+
assert.ok(hasSettingsSection('voz'));
|
|
53
|
+
assert.ok(!hasSettingsSection('inexistente'));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('los ids de las secciones no se repiten', () => {
|
|
57
|
+
const all = ids(SETTINGS_SECTIONS);
|
|
58
|
+
assert.equal(new Set(all).size, all.length);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('index.html tiene un boton y un panel por cada seccion, y ninguno de mas', () => {
|
|
62
|
+
const tabs = [...html.matchAll(/<button[^>]*class="ajustes-tab[^"]*"[^>]*data-panel="([a-z]+)"/g)].map((m) => m[1]);
|
|
63
|
+
const panels = [...html.matchAll(/<div class="panel ajustes-panel[^"]*"[^>]*data-panel="([a-z]+)"/g)].map((m) => m[1]);
|
|
64
|
+
const expected = ids(SETTINGS_SECTIONS).sort();
|
|
65
|
+
assert.deepEqual([...tabs].sort(), expected, 'botones del menu de ajustes');
|
|
66
|
+
assert.deepEqual([...panels].sort(), expected, 'paneles de ajustes');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('el menu de index.html respeta los grupos y el nombre de cada seccion', () => {
|
|
70
|
+
for (const group of SETTINGS_GROUPS) {
|
|
71
|
+
const block = html.match(new RegExp(`<div class="ajustes-group" data-group="${group.id}"[^>]*>([\\s\\S]*?)\\n </div>`));
|
|
72
|
+
assert.ok(block, `falta el grupo ${group.id}`);
|
|
73
|
+
assert.ok(block[1].includes(`>${group.label}</span>`), `titulo del grupo ${group.id}`);
|
|
74
|
+
const inBlock = [...block[1].matchAll(/data-panel="([a-z]+)"[^>]*>([^<]+)<\/button>/g)].map((m) => [m[1], m[2]]);
|
|
75
|
+
assert.deepEqual(
|
|
76
|
+
inBlock,
|
|
77
|
+
group.sections.map((section) => [section.id, section.label]),
|
|
78
|
+
`secciones del grupo ${group.id}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('cada tab apunta a su panel por aria-controls y el panel a su tab', () => {
|
|
84
|
+
for (const { id } of SETTINGS_SECTIONS) {
|
|
85
|
+
assert.match(html, new RegExp(`id="ajustes-tab-${id}"[^>]*aria-controls="ajustes-panel-${id}"`), `tab ${id}`);
|
|
86
|
+
assert.match(html, new RegExp(`id="ajustes-panel-${id}"[^>]*role="tabpanel"[^>]*aria-labelledby="ajustes-tab-${id}"`), `panel ${id}`);
|
|
87
|
+
}
|
|
88
|
+
const selected = html.match(/class="ajustes-tab[^"]*"[^>]*aria-selected="true"/g) ?? [];
|
|
89
|
+
assert.equal(selected.length, 1, 'exactamente una tab seleccionada al cargar');
|
|
90
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Lee los temas de `public/styles.css` tal como los ve el navegador y calcula
|
|
2
|
+
// contraste WCAG. Sirve a los tests de temas: no hay DOM, se parsea el texto.
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
const CSS_URL = new URL('../public/styles.css', import.meta.url);
|
|
6
|
+
|
|
7
|
+
export function readStyles() {
|
|
8
|
+
return readFileSync(CSS_URL, 'utf8').replace(/\r\n/g, '\n');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parseVars(body) {
|
|
12
|
+
const vars = {};
|
|
13
|
+
for (const m of body.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/gi)) vars[m[1]] = m[2].trim();
|
|
14
|
+
return vars;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `{ root: {...}, themes: { paper: {...}, ... } }` con las variables declaradas en cada bloque. */
|
|
18
|
+
export function parseThemeBlocks(css = readStyles()) {
|
|
19
|
+
const root = css.match(/(^|\n):root \{\n([\s\S]*?)\n\}/);
|
|
20
|
+
if (!root) throw new Error('no encontre :root');
|
|
21
|
+
const themes = {};
|
|
22
|
+
for (const m of css.matchAll(/^html\[data-theme='([a-z-]+)'\] \{\n([\s\S]*?)\n\}/gm)) {
|
|
23
|
+
themes[m[1]] = parseVars(m[2]);
|
|
24
|
+
}
|
|
25
|
+
return { root: parseVars(root[2]), themes };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Variables resueltas de un tema: las de `:root` pisadas por las del tema. */
|
|
29
|
+
export function resolveTheme(id, blocks = parseThemeBlocks()) {
|
|
30
|
+
if (id !== 'dark' && !blocks.themes[id]) throw new Error(`tema desconocido: ${id}`);
|
|
31
|
+
return { ...blocks.root, ...(blocks.themes[id] ?? {}) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function parseColor(value) {
|
|
35
|
+
const text = String(value).trim();
|
|
36
|
+
let m = text.match(/^#([0-9a-f]{3})$/i);
|
|
37
|
+
if (m) return { r: parseInt(m[1][0] + m[1][0], 16), g: parseInt(m[1][1] + m[1][1], 16), b: parseInt(m[1][2] + m[1][2], 16), a: 1 };
|
|
38
|
+
m = text.match(/^#([0-9a-f]{6})$/i);
|
|
39
|
+
if (m) return { r: parseInt(m[1].slice(0, 2), 16), g: parseInt(m[1].slice(2, 4), 16), b: parseInt(m[1].slice(4, 6), 16), a: 1 };
|
|
40
|
+
m = text.match(/^#([0-9a-f]{8})$/i);
|
|
41
|
+
if (m) return { r: parseInt(m[1].slice(0, 2), 16), g: parseInt(m[1].slice(2, 4), 16), b: parseInt(m[1].slice(4, 6), 16), a: parseInt(m[1].slice(6, 8), 16) / 255 };
|
|
42
|
+
m = text.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$/i);
|
|
43
|
+
if (m) return { r: Number(m[1]), g: Number(m[2]), b: Number(m[3]), a: m[4] === undefined ? 1 : Number(m[4]) };
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Pinta `top` (con alfa) sobre `base` opaco. */
|
|
48
|
+
export function composite(top, base) {
|
|
49
|
+
const a = top.a;
|
|
50
|
+
return {
|
|
51
|
+
r: Math.round(top.r * a + base.r * (1 - a)),
|
|
52
|
+
g: Math.round(top.g * a + base.g * (1 - a)),
|
|
53
|
+
b: Math.round(top.b * a + base.b * (1 - a)),
|
|
54
|
+
a: 1,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function channel(value) {
|
|
59
|
+
const s = value / 255;
|
|
60
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function luminance({ r, g, b }) {
|
|
64
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const TOKEN_NAMES = ['comment', 'string', 'number', 'keyword', 'tag', 'attr', 'selector', 'punct'];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Pisos de contraste de un tema ya resuelto. Texto de interfaz: AA (4.5). Texto
|
|
71
|
+
* principal: 7 (AAA). Colores de sintaxis: 3, porque son un apoyo visual sobre un
|
|
72
|
+
* bloque de codigo y no el texto que se lee.
|
|
73
|
+
*/
|
|
74
|
+
export function contrastChecks(vars) {
|
|
75
|
+
const color = (name) => {
|
|
76
|
+
const parsed = parseColor(vars[name]);
|
|
77
|
+
if (!parsed) throw new Error(`color ilegible en ${name}: ${vars[name]}`);
|
|
78
|
+
return parsed;
|
|
79
|
+
};
|
|
80
|
+
const bg = color('--bg');
|
|
81
|
+
const panel = composite(color('--panel'), bg);
|
|
82
|
+
const theirs = color('--theirs');
|
|
83
|
+
const solid = color('--solid');
|
|
84
|
+
const checks = [
|
|
85
|
+
{ name: 'text/bg', vars: ['--text', '--bg'], ratio: contrast(color('--text'), bg), min: 7 },
|
|
86
|
+
{ name: 'text/panel', vars: ['--text', '--panel'], ratio: contrast(color('--text'), panel), min: 7 },
|
|
87
|
+
{ name: 'muted/bg', vars: ['--muted', '--bg'], ratio: contrast(color('--muted'), bg), min: 4.5 },
|
|
88
|
+
{ name: 'muted/panel', vars: ['--muted', '--panel'], ratio: contrast(color('--muted'), panel), min: 4.5 },
|
|
89
|
+
{ name: 'muted/theirs', vars: ['--muted', '--theirs'], ratio: contrast(color('--muted'), theirs), min: 4.5 },
|
|
90
|
+
{ name: 'accent/bg', vars: ['--accent', '--bg'], ratio: contrast(color('--accent'), bg), min: 4.5 },
|
|
91
|
+
{ name: 'primary-fg/accent', vars: ['--primary-fg', '--accent'], ratio: contrast(color('--primary-fg'), color('--accent')), min: 4.5 },
|
|
92
|
+
];
|
|
93
|
+
for (const status of ['danger', 'ok', 'warn', 'info']) {
|
|
94
|
+
checks.push({ name: `${status}/bg`, vars: [`--${status}`, '--bg'], ratio: contrast(color(`--${status}`), bg), min: 4.5 });
|
|
95
|
+
}
|
|
96
|
+
// Indicador grafico (mic/manos libres activos): componente de interfaz, 3:1.
|
|
97
|
+
checks.push({ name: 'live/bg', vars: ['--live', '--bg'], ratio: contrast(color('--live'), bg), min: 3 });
|
|
98
|
+
for (const token of TOKEN_NAMES) {
|
|
99
|
+
checks.push({ name: `tok-${token}/solid`, vars: [`--tok-${token}`, '--solid'], ratio: contrast(color(`--tok-${token}`), solid), min: 3 });
|
|
100
|
+
}
|
|
101
|
+
return checks;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Relacion de contraste WCAG entre dos colores opacos (1 a 21). */
|
|
105
|
+
export function contrast(foreground, background) {
|
|
106
|
+
const l1 = luminance(foreground);
|
|
107
|
+
const l2 = luminance(background);
|
|
108
|
+
const [hi, lo] = l1 >= l2 ? [l1, l2] : [l2, l1];
|
|
109
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
110
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Los temas de la consola, verificados sobre el CSS real (sin navegador).
|
|
2
|
+
// Un tema nuevo (o un retoque de colores) no puede dejar texto ilegible ni una
|
|
3
|
+
// variable sin definir: eso se nota en pantalla recien cuando alguien lo usa.
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { THEMES } from '../public/appearance.js';
|
|
7
|
+
import { TOKEN_NAMES, contrastChecks, parseColor, parseThemeBlocks, readStyles, resolveTheme } from './themeCss.js';
|
|
8
|
+
|
|
9
|
+
const blocks = parseThemeBlocks();
|
|
10
|
+
const ids = THEMES.map((theme) => theme.id);
|
|
11
|
+
|
|
12
|
+
// Variables que no son color: las define :root y los temas no las repiten.
|
|
13
|
+
const NO_COLOR = new Set(['--sans', '--serif', '--mono', '--font', '--chat-font']);
|
|
14
|
+
|
|
15
|
+
test('cada tema de appearance.js tiene su bloque de CSS, y viceversa', () => {
|
|
16
|
+
const conBloque = ['dark', ...Object.keys(blocks.themes)];
|
|
17
|
+
assert.deepEqual([...conBloque].sort(), [...ids].sort());
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('el esquema declarado de cada tema coincide con su color-scheme', () => {
|
|
21
|
+
const css = readStyles();
|
|
22
|
+
for (const theme of THEMES) {
|
|
23
|
+
const open = theme.id === 'dark' ? ':root {' : `html[data-theme='${theme.id}'] {`;
|
|
24
|
+
const start = css.indexOf(open);
|
|
25
|
+
const body = css.slice(start, css.indexOf('\n}\n', start));
|
|
26
|
+
assert.match(body, new RegExp(`color-scheme: ${theme.scheme};`), `${theme.id} deberia ser ${theme.scheme}`);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('todos los temas definen las mismas variables de color que :root', () => {
|
|
31
|
+
const requeridas = Object.keys(blocks.root).filter((name) => !NO_COLOR.has(name));
|
|
32
|
+
assert.ok(requeridas.length > 25, 'no se leyeron las variables de :root');
|
|
33
|
+
for (const [id, vars] of Object.entries(blocks.themes)) {
|
|
34
|
+
const faltan = requeridas.filter((name) => !(name in vars));
|
|
35
|
+
assert.deepEqual(faltan, [], `el tema "${id}" no define: ${faltan.join(', ')}`);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('cada color de cada tema se puede leer', () => {
|
|
40
|
+
for (const id of ids) {
|
|
41
|
+
const vars = resolveTheme(id, blocks);
|
|
42
|
+
for (const [name, value] of Object.entries(vars)) {
|
|
43
|
+
if (NO_COLOR.has(name)) continue;
|
|
44
|
+
assert.ok(parseColor(value), `${id}: ${name} = "${value}" no es un color valido`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('la sintaxis se tematiza con variables, no con reglas copiadas por tema', () => {
|
|
50
|
+
const css = readStyles();
|
|
51
|
+
assert.ok(!/data-theme='[a-z-]+'\] \.tok-/.test(css), 'quedaron reglas .tok-* por tema');
|
|
52
|
+
for (const name of TOKEN_NAMES) {
|
|
53
|
+
assert.match(css, new RegExp(`\\.tok-${name} \\{ color: var\\(--tok-${name}\\);`), `.tok-${name} no usa su variable`);
|
|
54
|
+
for (const id of ids) assert.ok(resolveTheme(id, blocks)[`--tok-${name}`], `${id} sin --tok-${name}`);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
for (const id of ids) {
|
|
59
|
+
test(`contraste del tema "${id}" cumple los pisos (texto AA/AAA, sintaxis 3:1)`, () => {
|
|
60
|
+
const fallas = contrastChecks(resolveTheme(id, blocks))
|
|
61
|
+
.filter((check) => check.ratio < check.min)
|
|
62
|
+
.map((check) => `${check.name} ${check.ratio.toFixed(2)} < ${check.min}`);
|
|
63
|
+
assert.deepEqual(fallas, [], `${id}: ${fallas.join(' | ')}`);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
test('metaColor (barra del navegador) coincide con el fondo de cada tema', () => {
|
|
68
|
+
for (const theme of THEMES) {
|
|
69
|
+
const bg = resolveTheme(theme.id, blocks)['--bg'];
|
|
70
|
+
assert.equal(theme.metaColor.toLowerCase(), bg.toLowerCase(), `${theme.id}: metaColor ${theme.metaColor} != --bg ${bg}`);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Reinicio disparado desde la consola (POST /api/server/restart).
|
|
3
|
+
//
|
|
4
|
+
// Corre detached: espera a que el server viejo muera (libera el puerto y, en
|
|
5
|
+
// Windows, los archivos) y lo vuelve a levantar con el mismo comando con que
|
|
6
|
+
// arranco (el argv serializado por el server), sin tocar la instalacion. Todo se
|
|
7
|
+
// loguea en el log del server para diagnosticar desde `tamnora server --logs`.
|
|
8
|
+
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { appendFileSync } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
function arg(name) {
|
|
14
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
15
|
+
return i >= 0 ? process.argv[i + 1] : '';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const pid = Number(arg('pid')) || 0;
|
|
19
|
+
const home = arg('home') || process.env.TAMNORA_HOME || '';
|
|
20
|
+
let argv = [];
|
|
21
|
+
try {
|
|
22
|
+
argv = JSON.parse(arg('argv') || '[]');
|
|
23
|
+
} catch {
|
|
24
|
+
argv = [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function defaultHome() {
|
|
28
|
+
if (process.env.APPDATA) return path.join(process.env.APPDATA, 'tamnora');
|
|
29
|
+
if (process.platform === 'darwin') {
|
|
30
|
+
return path.join(process.env.HOME || '', 'Library', 'Application Support', 'tamnora');
|
|
31
|
+
}
|
|
32
|
+
return path.join(process.env.HOME || process.env.USERPROFILE || '', '.config', 'tamnora');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function log(line) {
|
|
36
|
+
const text = `[${new Date().toISOString()}] restart: ${line}`;
|
|
37
|
+
try {
|
|
38
|
+
appendFileSync(path.join(home || defaultHome(), 'logs', 'server.log'), `${text}\n`);
|
|
39
|
+
} catch {
|
|
40
|
+
/* sin log seguimos: el reinicio es lo importante */
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
process.stdout.write(`${text}\n`);
|
|
44
|
+
} catch {
|
|
45
|
+
/* stdout cerrado */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
50
|
+
|
|
51
|
+
function alive(p) {
|
|
52
|
+
if (!p) return false;
|
|
53
|
+
try {
|
|
54
|
+
process.kill(p, 0);
|
|
55
|
+
return true;
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function waitDead(p, ms = 15000) {
|
|
62
|
+
const start = Date.now();
|
|
63
|
+
while (alive(p)) {
|
|
64
|
+
if (Date.now() - start > ms) {
|
|
65
|
+
try {
|
|
66
|
+
process.kill(p);
|
|
67
|
+
} catch {
|
|
68
|
+
/* ya murio */
|
|
69
|
+
}
|
|
70
|
+
await sleep(1500);
|
|
71
|
+
return !alive(p);
|
|
72
|
+
}
|
|
73
|
+
await sleep(300);
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function main() {
|
|
79
|
+
log(`esperando que muera el server (pid ${pid})`);
|
|
80
|
+
await waitDead(pid);
|
|
81
|
+
await sleep(800);
|
|
82
|
+
|
|
83
|
+
if (!Array.isArray(argv) || !argv.length) {
|
|
84
|
+
log('no llego el argv que relanzar; levantalo a mano: tamnora server');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
log(`reiniciando: ${process.execPath} ${argv.join(' ')}`);
|
|
89
|
+
const env = { ...process.env, TAMNORA_DETACHED: '1' };
|
|
90
|
+
if (home) env.TAMNORA_HOME = home;
|
|
91
|
+
const child = spawn(process.execPath, argv, {
|
|
92
|
+
detached: true,
|
|
93
|
+
stdio: 'ignore',
|
|
94
|
+
windowsHide: true,
|
|
95
|
+
env,
|
|
96
|
+
});
|
|
97
|
+
child.unref();
|
|
98
|
+
log(`server reiniciado (pid ${child.pid}).`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
main().catch((error) => log(`error: ${error?.stack ?? error}`));
|
package/package.json
CHANGED