← back to Dot Palette
selftest.js
119 lines
#!/usr/bin/env node
// dot-palette self-test (TK-11877). Proves the FULL request path
// (HTTP -> setDot -> engine argv) works AND reddens on an injected engine
// failure, WITHOUT ever touching a real iTerm2 tab or the live :9791 palette.
//
// It boots server.js on an ephemeral port (PORT=0) with the test seams set:
// DOTPALETTE_ENGINE -> a mock engine that echoes its argv (or fails)
// DOTPALETTE_ENGINE_BIN -> python3
// DOTPALETTE_TTY_OVERRIDE-> a deterministic fake tty (no osascript, no real tab)
// DOTPALETTE_PORT_FILE -> a temp file (never clobbers the live .port)
// None of these are set by the Electron shell or launchd, so production is untouched.
//
// Doctrine (CLAUDE.md TK-11431 amд3): a check ships with a NEGATIVE test proving it
// goes RED on an injected fault, or it does not ship. Run: node selftest.js
'use strict';
const http = require('http');
const os = require('os');
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const DIR = __dirname;
const SERVER = path.join(DIR, 'server.js');
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dotpalette-selftest-'));
const GOOD_TTY = '/dev/ttys123';
// Mock engine: echoes argv (proves the server passed the right command); with
// MOCK_FAIL set it exits non-zero (proves the server surfaces engine failure).
const MOCK = path.join(TMP, 'mock-engine.py');
fs.writeFileSync(MOCK, [
'import os, sys',
'if os.environ.get("MOCK_FAIL"):',
' sys.stderr.write("mock engine forced failure"); sys.exit(1)',
'sys.stdout.write(" ".join(sys.argv[1:])); sys.exit(0)',
'',
].join('\n'));
let passed = 0, failed = 0;
function ok(cond, msg) { (cond ? passed++ : failed++); console.log(` ${cond ? 'PASS' : 'FAIL'} ${msg}`); }
function get(port, p) {
return new Promise((res, rej) => {
const r = http.get({ host: '127.0.0.1', port, path: p, timeout: 4000 }, x => {
let b = ''; x.on('data', c => (b += c)); x.on('end', () => res({ code: x.statusCode, body: b }));
});
r.on('error', rej); r.on('timeout', () => { r.destroy(); rej(new Error('timeout')); });
});
}
function post(port, p, obj) {
const data = Buffer.from(JSON.stringify(obj));
return new Promise((res, rej) => {
const r = http.request({ host: '127.0.0.1', port, path: p, method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': data.length }, timeout: 4000 },
x => { let b = ''; x.on('data', c => (b += c)); x.on('end', () => res({ code: x.statusCode, body: b })); });
r.on('error', rej); r.on('timeout', () => { r.destroy(); rej(new Error('timeout')); });
r.end(data);
});
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
// Boot server.js with the given extra env; return {proc, port}. Never writes the live .port.
async function boot(extraEnv, tag) {
const portFile = path.join(TMP, `port-${tag}`);
try { fs.rmSync(portFile, { force: true }); } catch {}
const proc = spawn(process.execPath, [SERVER], {
stdio: 'ignore',
env: { ...process.env, PORT: '0', DOTPALETTE_ENGINE: MOCK, DOTPALETTE_ENGINE_BIN: 'python3',
DOTPALETTE_TTY_OVERRIDE: GOOD_TTY, DOTPALETTE_PORT_FILE: portFile, ...extraEnv },
});
for (let i = 0; i < 60; i++) {
await sleep(100);
let port = null;
try { port = parseInt(fs.readFileSync(portFile, 'utf8'), 10); } catch {}
if (port) { try { const h = await get(port, '/health'); if (h.code === 200) return { proc, port }; } catch {} }
}
throw new Error(`server (${tag}) never came up`);
}
function kill(proc) { try { proc.kill(); } catch {} }
(async () => {
console.log('dot-palette selftest — isolated, no real tabs touched');
// --- POSITIVE: real engine mock succeeds, deterministic tty ---
const good = await boot({}, 'good');
try {
const h = await get(good.port, '/health');
ok(h.code === 200 && JSON.parse(h.body).ok === true, '/health -> 200 ok');
const bad = JSON.parse((await post(good.port, '/api/setdot', { color: 'NOTACOLOR' })).body);
ok(bad.ok === false && /bad color/.test(bad.error || ''), 'bad colour -> ok:false (no tty touched)');
const green = JSON.parse((await post(good.port, '/api/setdot', { color: 'green' })).body);
ok(green.ok === true && green.tty === GOOD_TTY, 'valid colour -> ok:true on the resolved tty');
ok(/^set green --tty \/dev\/ttys123$/.test((green.out || '').trim()),
'server passes engine the correct argv (set green --tty <tty>)');
const lbl = JSON.parse((await post(good.port, '/api/setdot', { color: 'purple', label: 'TK-11877 x' })).body);
ok(lbl.ok === true && /set purple TK-11877 x --tty/.test((lbl.out || '')), 'label is forwarded to the engine');
} finally { kill(good.proc); }
// --- NEGATIVE: engine exits non-zero -> the check MUST redden ---
const bad = await boot({ MOCK_FAIL: '1' }, 'fail');
try {
const r = JSON.parse((await post(bad.port, '/api/setdot', { color: 'green' })).body);
ok(r.ok === false, 'NEGATIVE: engine failure -> ok:false (reddens on injected fault)');
} finally { kill(bad.proc); }
// --- NEGATIVE: no resolvable tty -> ok:false, engine never invoked ---
const notty = await boot({ DOTPALETTE_TTY_OVERRIDE: 'not-a-tty' }, 'notty');
try {
const r = JSON.parse((await post(notty.port, '/api/setdot', { color: 'green' })).body);
ok(r.ok === false && /no current iTerm2/.test(r.error || ''), 'NEGATIVE: unresolvable tty -> ok:false');
} finally { kill(notty.proc); }
try { fs.rmSync(TMP, { recursive: true, force: true }); } catch {}
console.log(`selftest: ${failed ? 'FAIL' : 'PASS'} (${passed}/${passed + failed})`);
process.exit(failed ? 1 : 0);
})().catch(e => { console.error('selftest ERROR', e && e.stack || e); process.exit(2); });