tests/publish.test.js (12762 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 fc from 'fast-check'; 7 import * as config from '../src/config.js'; 8 import * as embed from '../src/embed.js'; 9 import * as publish from '../src/publish.js'; 10 import * as store from '../src/store.js'; 11 import { startFakeServer } from './fixtures/fake-server.js'; 12 13 let server; 14 15 beforeEach(async () => { 16 process.env.IDEAMINE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-publish-')); 17 server = await startFakeServer(); 18 process.env.IDEAMINE_EMBED_URL = `${server.url}/v1`; 19 for (const name of ['IDEAMINE_PUBLISH_URL', 'IDEAMINE_EMBED_MODEL', 'IDEAMINE_GROUP_THRESHOLD', 'IDEAMINE_SEARCH_THRESHOLD']) delete process.env[name]; 20 }); 21 22 afterEach(() => server.close()); 23 24 const T = (h) => `2026-09-21T${String(h).padStart(2, '0')}:00:00.000Z`; 25 26 test('timeline phases follow the inbox, the queue, and the work', () => { 27 const base = { id: 1, created: T(1), status: 'inbox' }; 28 assert.deepEqual(publish.phases(base), [{ phase: 'inbox', from: T(1), to: null }]); 29 const queued = { ...base, status: 'triaged', triage: { at: T(2) } }; 30 assert.deepEqual(publish.phases(queued), [ 31 { phase: 'inbox', from: T(1), to: T(2) }, 32 { phase: 'queued', from: T(2), to: null }, 33 ]); 34 const done = { ...queued, status: 'done', started: T(3), closed: T(5) }; 35 assert.deepEqual(publish.phases(done), [ 36 { phase: 'inbox', from: T(1), to: T(2) }, 37 { phase: 'queued', from: T(2), to: T(3) }, 38 { phase: 'doing', from: T(3), to: T(5) }, 39 ]); 40 // Done before start times were recorded: the work bar is a guess, and says so. 41 assert.deepEqual(publish.phases({ ...queued, status: 'done', closed: T(5) }), [ 42 { phase: 'inbox', from: T(1), to: T(2) }, 43 { phase: 'doing', from: T(2), to: T(5), estimated: true }, 44 ]); 45 assert.deepEqual(publish.phases({ ...base, status: 'dropped', closed: T(4) }), [{ phase: 'inbox', from: T(1), to: T(4) }]); 46 }); 47 48 test('the snapshot has a ticket for each idea, groups, related ideas, and the vectors', async () => { 49 process.env.IDEAMINE_GROUP_THRESHOLD = '0.5'; 50 store.addIdeas(['subtitles for audiobooks', 'subtitles for audiobooks on android', 'a tor exit relay'], { project: '/work/app' }); 51 store.applyTriage([{ id: 3, verdict: 'do', impact: 4, size: 's', model: 'haiku', title: 'Tor relay', why: 'w', brief: 'b' }]); 52 store.updateIdea(3, { status: 'doing' }); 53 const { data, note } = await publish.build(store.load()); 54 assert.equal(note, ''); 55 assert.equal(data.version, 1); 56 assert.equal(data.embed.available, true); 57 assert.equal(data.embed.query_prefix, 'search_query: '); 58 assert.equal(data.counts.doing, 1); 59 const [one, two, three] = data.ideas; 60 assert.deepEqual([one.key, one.lane, one.project, three.lane, three.model], ['IDEA-1', 'inbox', 'app', 'doing', 'haiku']); 61 assert.deepEqual(data.ideas.map((i) => i.rank), [2, 1, 0]); // doing first, then the inbox, newest first 62 assert.deepEqual(data.groups, [{ id: 0, label: data.groups[0].label, ids: [1, 2] }]); 63 assert.deepEqual([one.group, two.group, three.group], [0, 0, null]); 64 assert.equal(one.related[0].id, 2); 65 assert.ok(three.phases.some((p) => p.phase === 'doing' && p.from === three.started)); 66 const vectors = await embed.vectorsFor(store.load().ideas); 67 assert.deepEqual([...embed.decodeVec(one.vec)], [...vectors.get(1)]); 68 }); 69 70 test('without the embedding server, the snapshot has no groups or vectors, and says why', async () => { 71 store.addIdeas(['one idea']); 72 server.options.down = true; 73 const { data, note } = await publish.build(store.load()); 74 assert.match(note, /without search by meaning or groups: .*503/); 75 assert.equal(data.embed.available, false); 76 assert.deepEqual([data.groups, data.ideas[0].vec, data.ideas[0].related], [[], null, []]); 77 }); 78 79 test('publish uploads data.json and index.html with PUT, and kick() starts again only after a change', async () => { 80 store.addIdeas(['one idea']); 81 let started = 0; 82 const kick = () => publish.kick({ start: () => started++ }); 83 assert.equal(kick(), false); // no dashboard server set 84 config.set('publish_url', `${server.url}/dash`); 85 assert.equal(kick(), true); // never published 86 const out = await publish.publish(); 87 assert.deepEqual([out.where, out.ideas], [`${server.url}/dash`, 1]); 88 assert.deepEqual([...server.files.keys()].sort(), [ 89 '/dash/apple-touch-icon.png', 90 '/dash/data.json', 91 '/dash/icon-192.png', 92 '/dash/icon-512.png', 93 '/dash/icon-maskable-512.png', 94 '/dash/index.html', 95 '/dash/manifest.webmanifest', 96 '/dash/sw.js', 97 ]); 98 assert.equal(JSON.parse(server.files.get('/dash/data.json')).ideas[0].title, 'one idea'); 99 assert.match(server.files.get('/dash/index.html'), /<html/i); 100 assert.equal(kick(), false); // nothing changed 101 await new Promise((resolve) => setTimeout(resolve, 20)); // a later modification time 102 store.addIdeas(['two']); 103 assert.equal(kick(), true); 104 assert.equal(started, 2); 105 }); 106 107 test('a failed background publish is recorded, and kick() waits before the next try', async () => { 108 store.addIdeas(['one idea']); 109 const closed = await startFakeServer(); 110 await closed.close(); 111 config.set('publish_url', `${closed.url}/`); 112 assert.equal(await publish.backgroundPublish(), 'error'); 113 assert.match(publish.readState().error, /cannot reach/); 114 assert.equal(publish.kick({ start: () => assert.fail('must wait') }), false); 115 assert.match(publish.status(), /failed/); 116 }); 117 118 test('a background publish that cannot start is recorded, and the caller goes on', async () => { 119 store.addIdeas(['one idea']); 120 config.set('publish_url', `${server.url}/`); 121 const node = process.execPath; 122 process.execPath = path.join(store.home(), 'no-such-node'); 123 try { 124 assert.equal(publish.kick(), true); 125 await new Promise((resolve) => setTimeout(resolve, 300)); // the spawn error comes later 126 } finally { 127 process.execPath = node; 128 } 129 assert.match(publish.readState().error, /^cannot start a publish: .*ENOENT/); 130 assert.equal(publish.kick({ start: () => assert.fail('must wait after the error') }), false); 131 }); 132 133 test('publish --dir writes the page, the data, and what a phone needs', async () => { 134 store.addIdeas(['one idea']); 135 const dir = path.join(store.home(), 'out'); 136 const out = await publish.publish({ dir }); 137 assert.equal(out.where, dir); 138 assert.deepEqual(fs.readdirSync(dir).sort(), [ 139 'apple-touch-icon.png', 140 'data.json', 141 'icon-192.png', 142 'icon-512.png', 143 'icon-maskable-512.png', 144 'index.html', 145 'manifest.webmanifest', 146 'sw.js', 147 ]); 148 assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'manifest.webmanifest'), 'utf8')).name, 'ideamine'); 149 }); 150 151 test('config: the environment wins over the file, and an empty value restores the default', () => { 152 assert.equal(config.get('search_threshold'), 0.5); 153 assert.equal(config.set('search_threshold', '0.42'), 0.42); 154 process.env.IDEAMINE_SEARCH_THRESHOLD = '0.9'; 155 assert.equal(config.get('search_threshold'), 0.9); 156 delete process.env.IDEAMINE_SEARCH_THRESHOLD; 157 assert.equal(config.set('search_threshold', ''), 0.5); 158 assert.throws(() => config.set('search_threshold', '2'), /from 0 to 1/); 159 assert.throws(() => config.get('nope'), /unknown setting/); 160 assert.match(config.describe(), /^embed_url\s+http:\/\/127\.0\.0\.1:\d+\/v1\s+IDEAMINE_EMBED_URL/); 161 }); 162 163 // ---- the Flow view: lane over time, cumulative flow, and the tiles ---- 164 165 const iso = (ms) => new Date(ms).toISOString(); 166 const NOW = 4_000_000; 167 168 /** An idea with ordered times: created ≤ triaged ≤ started ≤ closed, each stage optional. */ 169 const ideaArb = fc 170 .record({ 171 id: fc.integer({ min: 1, max: 1000 }), 172 created: fc.integer({ min: 0, max: 1_000_000 }), 173 gaps: fc.tuple(fc.integer({ min: 0, max: 500_000 }), fc.integer({ min: 0, max: 500_000 }), fc.integer({ min: 0, max: 500_000 })), 174 stage: fc.constantFrom('inbox', 'triaged', 'doing', 'done', 'dropped'), 175 triaged: fc.boolean(), 176 hasStart: fc.boolean(), 177 verdict: fc.constantFrom('do', 'maybe', 'skip'), 178 }) 179 .map(({ id, created, gaps, stage, triaged, hasStart, verdict }) => { 180 const idea = { id, created: iso(created), status: stage === 'triaged' ? 'triaged' : stage, updated: iso(created) }; 181 let t = created; 182 if (stage === 'triaged' || (triaged && stage !== 'inbox')) idea.triage = { at: iso((t += gaps[0])), verdict }; 183 if (hasStart && ['doing', 'done', 'dropped'].includes(stage)) idea.started = iso((t += gaps[1])); 184 if (stage === 'done' || stage === 'dropped') idea.closed = iso((t += gaps[2])); 185 idea.updated = iso(t); 186 return idea; 187 }); 188 189 test('laneAt() agrees with lane() once every time is in the past, and is null before the idea exists', () => { 190 fc.assert( 191 fc.property(ideaArb, (idea) => { 192 assert.equal(publish.laneAt(idea, NOW), store.lane(idea)); 193 assert.equal(publish.laneAt(idea, Date.parse(idea.created) - 1), null); 194 assert.notEqual(publish.laneAt(idea, Date.parse(idea.created)), null); 195 }), 196 ); 197 }); 198 199 test('laneAt() walks the stages in order', () => { 200 const idea = { id: 1, created: T(1), status: 'done', triage: { at: T(2), verdict: 'do' }, started: T(3), closed: T(5) }; 201 const at = (h) => publish.laneAt(idea, Date.parse(T(h))); 202 assert.deepEqual([at(0), at(1), at(2), at(3), at(4), at(5), at(9)], [null, 'inbox', 'do', 'doing', 'doing', 'done', 'done']); 203 // In work before ideamine recorded start times: the work is estimated from the triage, like phases(). 204 assert.equal(publish.laneAt({ id: 2, created: T(1), status: 'doing', triage: { at: T(2), verdict: 'do' } }, Date.parse(T(3))), 'doing'); 205 assert.equal(publish.laneAt({ id: 2, created: T(1), status: 'doing', triage: { at: T(2), verdict: 'do' } }, Date.parse(T(1))), 'inbox'); 206 }); 207 208 test('flow(): at every sample, the lanes add up to the ideas that existed then', () => { 209 fc.assert( 210 fc.property(fc.array(ideaArb, { minLength: 0, maxLength: 30 }), fc.integer({ min: 2, max: 80 }), (ideas, samples) => { 211 const { times, series } = publish.flow(ideas, { samples, now: NOW }); 212 assert.deepEqual(Object.keys(series).sort(), [...store.LANES].sort()); 213 for (const lane of store.LANES) assert.equal(series[lane].length, times.length); 214 if (!ideas.length) return assert.equal(times.length, 0); 215 assert.ok(times.length >= 2 && times.length <= samples); 216 assert.equal(times[times.length - 1], iso(NOW)); 217 for (let k = 0; k < times.length; k++) { 218 const t = Date.parse(times[k]); 219 if (k > 0) assert.ok(t > Date.parse(times[k - 1]), 'times are strictly increasing'); 220 const total = store.LANES.reduce((sum, lane) => sum + series[lane][k], 0); 221 assert.equal(total, ideas.filter((i) => Date.parse(i.created) <= t).length); 222 } 223 }), 224 ); 225 }); 226 227 test('stats(): open count, the last 7 days, medians, the oldest open idea, and the model mix', () => { 228 const now = Date.parse('2026-09-22T00:00:00.000Z'); 229 const day = (n, h = 0) => new Date(now - n * 86400000 + h * 3600000).toISOString(); 230 const ideas = [ 231 { id: 1, created: day(10), status: 'inbox' }, 232 { id: 2, created: day(9), status: 'triaged', triage: { at: day(8), verdict: 'do', model: 'haiku', size: 's' } }, 233 { id: 3, created: day(9), status: 'triaged', triage: { at: day(8), verdict: 'skip', model: 'opus', size: 'l' } }, 234 { id: 4, created: day(6), status: 'doing', triage: { at: day(5), verdict: 'do', model: 'sonnet', size: 'm' }, started: day(4) }, 235 { id: 5, created: day(8), status: 'done', started: day(3), closed: day(1) }, 236 { id: 6, created: day(30), status: 'done', started: day(20), closed: day(10) }, 237 { id: 7, created: day(2), status: 'dropped', closed: day(1) }, 238 ]; 239 const s = publish.stats(ideas, { now }); 240 assert.equal(s.open, 3); // #1 inbox, #2 do, #4 doing 241 assert.deepEqual([s.done_7d, s.dropped_7d], [1, 1]); 242 assert.equal(s.lead_median_ms, (7 + 20) / 2 * 86400000); // #5: 7 days, #6: 20 days 243 assert.equal(s.cycle_median_ms, (2 + 10) / 2 * 86400000); 244 assert.equal(s.oldest_open_ms, 10 * 86400000); 245 assert.deepEqual(s.models, { haiku: 1, sonnet: 1, opus: 0, fable: 0 }); 246 assert.deepEqual(s.sizes, { xs: 0, s: 1, m: 1, l: 0, xl: 0 }); 247 const empty = publish.stats([], { now }); 248 assert.deepEqual([empty.open, empty.lead_median_ms, empty.cycle_median_ms, empty.oldest_open_ms], [0, null, null, null]); 249 }); 250 251 test('the snapshot carries the flow series and the stats', async () => { 252 store.addIdeas(['one idea']); 253 const { data } = await publish.build(store.load()); 254 assert.equal(data.flow.times.length, data.flow.series.inbox.length); 255 assert.equal(data.flow.series.inbox.at(-1), 1); 256 assert.equal(data.stats.open, 1); 257 });