tests/publish.test.js (6929 bytes)
1 import assert from 'node:assert/strict'; 2 import fs from 'node:fs'; 3 import os from 'node:os'; 4 import path from 'node:path'; 5 import { afterEach, beforeEach, test } from 'node:test'; 6 import * as config from '../src/config.js'; 7 import * as embed from '../src/embed.js'; 8 import * as publish from '../src/publish.js'; 9 import * as store from '../src/store.js'; 10 import { startFakeServer } from './fixtures/fake-server.js'; 11 12 let server; 13 14 beforeEach(async () => { 15 process.env.IDEAMINE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-publish-')); 16 server = await startFakeServer(); 17 process.env.IDEAMINE_EMBED_URL = `${server.url}/v1`; 18 for (const name of ['IDEAMINE_PUBLISH_URL', 'IDEAMINE_EMBED_MODEL', 'IDEAMINE_GROUP_THRESHOLD', 'IDEAMINE_SEARCH_THRESHOLD']) delete process.env[name]; 19 }); 20 21 afterEach(() => server.close()); 22 23 const T = (h) => `2026-09-21T${String(h).padStart(2, '0')}:00:00.000Z`; 24 25 test('timeline phases follow the inbox, the queue, and the work', () => { 26 const base = { id: 1, created: T(1), status: 'inbox' }; 27 assert.deepEqual(publish.phases(base), [{ phase: 'inbox', from: T(1), to: null }]); 28 const queued = { ...base, status: 'triaged', triage: { at: T(2) } }; 29 assert.deepEqual(publish.phases(queued), [ 30 { phase: 'inbox', from: T(1), to: T(2) }, 31 { phase: 'queued', from: T(2), to: null }, 32 ]); 33 const done = { ...queued, status: 'done', started: T(3), closed: T(5) }; 34 assert.deepEqual(publish.phases(done), [ 35 { phase: 'inbox', from: T(1), to: T(2) }, 36 { phase: 'queued', from: T(2), to: T(3) }, 37 { phase: 'doing', from: T(3), to: T(5) }, 38 ]); 39 // Done before start times were recorded: the work bar is a guess, and says so. 40 assert.deepEqual(publish.phases({ ...queued, status: 'done', closed: T(5) }), [ 41 { phase: 'inbox', from: T(1), to: T(2) }, 42 { phase: 'doing', from: T(2), to: T(5), estimated: true }, 43 ]); 44 assert.deepEqual(publish.phases({ ...base, status: 'dropped', closed: T(4) }), [{ phase: 'inbox', from: T(1), to: T(4) }]); 45 }); 46 47 test('the snapshot has a ticket for each idea, groups, related ideas, and the vectors', async () => { 48 process.env.IDEAMINE_GROUP_THRESHOLD = '0.5'; 49 store.addIdeas(['subtitles for audiobooks', 'subtitles for audiobooks on android', 'a tor exit relay'], { project: '/work/app' }); 50 store.applyTriage([{ id: 3, verdict: 'do', impact: 4, size: 's', model: 'haiku', title: 'Tor relay', why: 'w', brief: 'b' }]); 51 store.updateIdea(3, { status: 'doing' }); 52 const { data, note } = await publish.build(store.load()); 53 assert.equal(note, ''); 54 assert.equal(data.version, 1); 55 assert.equal(data.embed.available, true); 56 assert.equal(data.embed.query_prefix, 'search_query: '); 57 assert.equal(data.counts.doing, 1); 58 const [one, two, three] = data.ideas; 59 assert.deepEqual([one.key, one.lane, one.project, three.lane, three.model], ['IDEA-1', 'inbox', 'app', 'doing', 'haiku']); 60 assert.deepEqual(data.ideas.map((i) => i.rank), [2, 1, 0]); // doing first, then the inbox, newest first 61 assert.deepEqual(data.groups, [{ id: 0, label: data.groups[0].label, ids: [1, 2] }]); 62 assert.deepEqual([one.group, two.group, three.group], [0, 0, null]); 63 assert.equal(one.related[0].id, 2); 64 assert.ok(three.phases.some((p) => p.phase === 'doing' && p.from === three.started)); 65 const vectors = await embed.vectorsFor(store.load().ideas); 66 assert.deepEqual([...embed.decodeVec(one.vec)], [...vectors.get(1)]); 67 }); 68 69 test('without the embedding server, the snapshot has no groups or vectors, and says why', async () => { 70 store.addIdeas(['one idea']); 71 server.options.down = true; 72 const { data, note } = await publish.build(store.load()); 73 assert.match(note, /without search by meaning or groups: .*503/); 74 assert.equal(data.embed.available, false); 75 assert.deepEqual([data.groups, data.ideas[0].vec, data.ideas[0].related], [[], null, []]); 76 }); 77 78 test('publish uploads data.json and index.html with PUT, and kick() starts again only after a change', async () => { 79 store.addIdeas(['one idea']); 80 let started = 0; 81 const kick = () => publish.kick({ start: () => started++ }); 82 assert.equal(kick(), false); // no dashboard server set 83 config.set('publish_url', `${server.url}/dash`); 84 assert.equal(kick(), true); // never published 85 const out = await publish.publish(); 86 assert.deepEqual([out.where, out.ideas], [`${server.url}/dash`, 1]); 87 assert.deepEqual([...server.files.keys()], ['/dash/data.json', '/dash/index.html']); 88 assert.equal(JSON.parse(server.files.get('/dash/data.json')).ideas[0].title, 'one idea'); 89 assert.match(server.files.get('/dash/index.html'), /<html/i); 90 assert.equal(kick(), false); // nothing changed 91 await new Promise((resolve) => setTimeout(resolve, 20)); // a later modification time 92 store.addIdeas(['two']); 93 assert.equal(kick(), true); 94 assert.equal(started, 2); 95 }); 96 97 test('a failed background publish is recorded, and kick() waits before the next try', async () => { 98 store.addIdeas(['one idea']); 99 const closed = await startFakeServer(); 100 await closed.close(); 101 config.set('publish_url', `${closed.url}/`); 102 assert.equal(await publish.backgroundPublish(), 'error'); 103 assert.match(publish.readState().error, /cannot reach/); 104 assert.equal(publish.kick({ start: () => assert.fail('must wait') }), false); 105 assert.match(publish.status(), /failed/); 106 }); 107 108 test('a background publish that cannot start is recorded, and the caller goes on', async () => { 109 store.addIdeas(['one idea']); 110 config.set('publish_url', `${server.url}/`); 111 const node = process.execPath; 112 process.execPath = path.join(store.home(), 'no-such-node'); 113 try { 114 assert.equal(publish.kick(), true); 115 await new Promise((resolve) => setTimeout(resolve, 300)); // the spawn error comes later 116 } finally { 117 process.execPath = node; 118 } 119 assert.match(publish.readState().error, /^cannot start a publish: .*ENOENT/); 120 assert.equal(publish.kick({ start: () => assert.fail('must wait after the error') }), false); 121 }); 122 123 test('publish --dir writes both files to a folder', async () => { 124 store.addIdeas(['one idea']); 125 const dir = path.join(store.home(), 'out'); 126 const out = await publish.publish({ dir }); 127 assert.equal(out.where, dir); 128 assert.deepEqual(fs.readdirSync(dir).sort(), ['data.json', 'index.html']); 129 }); 130 131 test('config: the environment wins over the file, and an empty value restores the default', () => { 132 assert.equal(config.get('search_threshold'), 0.5); 133 assert.equal(config.set('search_threshold', '0.42'), 0.42); 134 process.env.IDEAMINE_SEARCH_THRESHOLD = '0.9'; 135 assert.equal(config.get('search_threshold'), 0.9); 136 delete process.env.IDEAMINE_SEARCH_THRESHOLD; 137 assert.equal(config.set('search_threshold', ''), 0.5); 138 assert.throws(() => config.set('search_threshold', '2'), /from 0 to 1/); 139 assert.throws(() => config.get('nope'), /unknown setting/); 140 assert.match(config.describe(), /^embed_url\s+http:\/\/127\.0\.0\.1:\d+\/v1\s+IDEAMINE_EMBED_URL/); 141 });