events.ts's handlers for a download in progress, finished and failed, and for Signals' new hits, ran in no test. The fixture server has a show to download from by hand: one episode sent over two seconds and one that always fails, added with a word nothing has so that nothing downloads on its own. Downloading the slow one fills its bar, the count line says how far, the toast names the file and the row shows it downloaded; another person with a page open, not subscribed, hears of none of it (#294). The broken one says which item failed and what to do (#304), and its bar stops. A Signal open on its page shows its new hits and its count as soon as a pass picks them out, without a reload; its folder is opened from the keyboard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
85 lines
4.4 KiB
TypeScript
85 lines
4.4 KiB
TypeScript
// Serves the fixture feeds so the daemon under test has something real to scan.
|
|
import http from 'node:http';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
const dir = import.meta.dirname;
|
|
const port = Number(process.env.FIXTURE_PORT || 8792);
|
|
|
|
http.createServer((req, res) => {
|
|
// Jev, for Signals (#254): sure of an item whose text has a question's keyword in it. Refuses
|
|
// a request in the shape TypeSafe refuses, as it does (#257).
|
|
if (req.method === 'POST' && req.url === '/jev') {
|
|
let body = '';
|
|
req.on('data', c => { body += c; });
|
|
req.on('end', () => {
|
|
// As TypeSafe answers a key it does not know (#271).
|
|
if (req.headers.authorization === 'Bearer bad-key') {
|
|
res.writeHead(401, { 'content-type': 'application/json' }).end('{"detail":"invalid api key"}');
|
|
return;
|
|
}
|
|
// As ipx asks TypeSafe's Jev: the item, and a question a Signal.
|
|
const { state, questions } = JSON.parse(body) as { state: unknown, questions: Record<string, { criteria: unknown, instructions: string }> };
|
|
if (Object.values(questions).some(q => typeof q.criteria !== 'object')) {
|
|
res.writeHead(422, { 'content-type': 'application/json' }).end('{"detail":"criteria should be an object"}');
|
|
return;
|
|
}
|
|
const title = String(state).toLowerCase();
|
|
const answers: Record<string, { noul: number }> = {};
|
|
for (const [id, q] of Object.entries(questions)) {
|
|
// A key's check asks about no Signal's words.
|
|
const words = /described by these words: ([^.]*)\./.exec(q.instructions)?.[1].split(', ') ?? [];
|
|
answers[id] = { noul: words.some(w => title.includes(w.toLowerCase())) ? 0.9 : 0.1 };
|
|
}
|
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ answers }));
|
|
});
|
|
return;
|
|
}
|
|
// Dated today, since a Signal looks at the last 30 days only.
|
|
if (req.url === '/signal.xml') {
|
|
const now = new Date().toUTCString();
|
|
const item = (guid: string, title: string) => `<item><guid>${guid}</guid><title>${title}</title><pubDate>${now}</pubDate></item>`;
|
|
res.writeHead(200, { 'content-type': 'application/xml' }).end(`<?xml version="1.0"?><rss version="2.0"><channel>
|
|
<title>Signal Show</title>${item('sig-1', 'Monaco qualifying')}${item('sig-2', 'Baking bread')}</channel></rss>`);
|
|
return;
|
|
}
|
|
// A show to download from by hand (#404): one episode sent slowly enough for its progress to be
|
|
// seen, and one that always fails. Their files are theirs alone, as enclosure URLs must be.
|
|
if (req.url === '/live.xml') {
|
|
const item = (guid: string, title: string, file: string) =>
|
|
`<item><guid>${guid}</guid><title>${title}</title><enclosure url="http://127.0.0.1:${port}/${file}" type="audio/mpeg" length="40000"/></item>`;
|
|
res.writeHead(200, { 'content-type': 'application/xml' }).end(`<?xml version="1.0"?><rss version="2.0"><channel>
|
|
<title>Live Show</title>${item('live-1', 'Slow episode', 'live-slow.mp3')}${item('live-2', 'Broken episode', 'live-broken.mp3')}</channel></rss>`);
|
|
return;
|
|
}
|
|
if (req.url === '/live-broken.mp3') { res.writeHead(500).end('broken'); return; }
|
|
if (req.url === '/live-slow.mp3') {
|
|
const body = fs.readFileSync(path.join(dir, 'ep1.mp3'));
|
|
res.writeHead(200, { 'content-type': 'audio/mpeg', 'content-length': body.length });
|
|
// Ten parts, a fifth of a second apart: two seconds of progress.
|
|
let part = 0;
|
|
const step = Math.ceil(body.length / 10);
|
|
const next = () => {
|
|
if (part * step >= body.length) { res.end(); return; }
|
|
res.write(body.subarray(part * step, ++part * step));
|
|
setTimeout(next, 200);
|
|
};
|
|
next();
|
|
return;
|
|
}
|
|
const name = decodeURIComponent((req.url ?? '/').split('?')[0]!.replace(/^\//, '')) || 'index';
|
|
const file = path.join(dir, path.basename(name));
|
|
fs.readFile(file, (err, body) => {
|
|
if (err) { res.writeHead(404).end('no'); return; }
|
|
const type = file.endsWith('.mp3') ? 'audio/mpeg'
|
|
: file.endsWith('.opml') ? 'text/x-opml'
|
|
// Artwork as an image, or /api/art refuses it as not one.
|
|
: file.endsWith('.jpg') ? 'image/jpeg'
|
|
// A picture a browser can draw: the JPEGs are a few bytes that only say they are one.
|
|
: file.endsWith('.png') ? 'image/png'
|
|
: file.endsWith('.html') ? 'text/html' : 'application/xml';
|
|
res.writeHead(200, { 'content-type': type, 'content-length': body.length });
|
|
res.end(body);
|
|
});
|
|
}).listen(port, '127.0.0.1', () => console.log(`fixtures on ${port}`));
|