| 1 | // `node --test node-tui/sessions.test.ts` |
| 2 | // |
| 3 | // Runs against a temporary directory via STRUDEL_SESSIONS, so it never touches |
| 4 | // a real library. |
| 5 | import { test, before, after } from 'node:test'; |
| 6 | import assert from 'node:assert/strict'; |
| 7 | import fs from 'node:fs/promises'; |
| 8 | import os from 'node:os'; |
| 9 | import path from 'node:path'; |
| 10 | |
| 11 | const DIR = await fs.mkdtemp(path.join(os.tmpdir(), 'strudel-sessions-')); |
| 12 | process.env['STRUDEL_SESSIONS'] = DIR; |
| 13 | |
| 14 | // Imported after the env var is set — SESSIONS_DIR is resolved at module load. |
| 15 | const sessions = await import('./sessions.ts'); |
| 16 | import type { Turn } from './claude.ts'; |
| 17 | |
| 18 | after(async () => { await fs.rm(DIR, { recursive: true, force: true }); }); |
| 19 | before(async () => { await fs.mkdir(DIR, { recursive: true }); }); |
| 20 | |
| 21 | const HISTORY: Turn[] = [ |
| 22 | { role: 'user', content: 'make me a techno pattern' }, |
| 23 | { role: 'assistant', content: 'Here you go.', code: 's("bd*4")' }, |
| 24 | ]; |
| 25 | |
| 26 | test('a saved session round-trips code and history together', async () => { |
| 27 | await sessions.save('techno', 's("bd*4")', HISTORY); |
| 28 | const [meta] = await sessions.list(); |
| 29 | assert.ok(meta); |
| 30 | const loaded = await sessions.load(meta); |
| 31 | assert.equal(loaded.code, 's("bd*4")'); |
| 32 | assert.deepEqual(loaded.history, HISTORY); |
| 33 | }); |
| 34 | |
| 35 | test('names are slugified into something a shell will not fight', () => { |
| 36 | assert.equal(sessions.slugify('Make Me A Techno!! pattern'), 'make-me-a-techno-pattern'); |
| 37 | assert.equal(sessions.slugify(' '), 'untitled'); |
| 38 | assert.equal(sessions.slugify('../../etc/passwd'), 'etc-passwd'); |
| 39 | }); |
| 40 | |
| 41 | test('a name is derived from the first user message', () => { |
| 42 | assert.equal(sessions.nameFrom(HISTORY), 'make me a techno pattern'); |
| 43 | assert.equal(sessions.nameFrom([]), null); |
| 44 | // Assistant-first history has nothing to name itself after. |
| 45 | assert.equal(sessions.nameFrom([{ role: 'assistant', content: 'hi' }]), null); |
| 46 | }); |
| 47 | |
| 48 | test('a long first message is truncated', () => { |
| 49 | const long = 'x'.repeat(100); |
| 50 | const name = sessions.nameFrom([{ role: 'user', content: long }]); |
| 51 | assert.equal(name?.length, 33); // 32 chars + ellipsis |
| 52 | assert.ok(name?.endsWith('…')); |
| 53 | }); |
| 54 | |
| 55 | test('.str files are listed as code-only sessions', async () => { |
| 56 | await fs.writeFile(path.join(DIR, 'plain.str'), 's("hh*8")\n'); |
| 57 | const list = await sessions.list(); |
| 58 | const plain = list.find((s) => s.name === 'plain'); |
| 59 | assert.ok(plain); |
| 60 | assert.equal(plain.hasHistory, false); |
| 61 | const loaded = await sessions.load(plain); |
| 62 | assert.equal(loaded.code.trim(), 's("hh*8")'); |
| 63 | assert.deepEqual(loaded.history, []); |
| 64 | }); |
| 65 | |
| 66 | test('a JSON session shadows a .str export of the same name', async () => { |
| 67 | await sessions.save('both', 's("json")', HISTORY); |
| 68 | await sessions.exportPlain('both', 's("plain")'); |
| 69 | const matches = (await sessions.list()).filter((s) => s.name === 'both'); |
| 70 | assert.equal(matches.length, 1, 'the same name must not appear twice'); |
| 71 | assert.equal(matches[0]?.hasHistory, true); |
| 72 | }); |
| 73 | |
| 74 | test('a corrupt history costs the transcript, not the pattern', async () => { |
| 75 | const file = path.join(DIR, 'broken.json'); |
| 76 | await fs.writeFile(file, JSON.stringify({ code: 's("bd")', history: 'not an array' })); |
| 77 | const loaded = await sessions.load({ |
| 78 | name: 'broken', file, updatedAt: 0, hasHistory: true, |
| 79 | }); |
| 80 | assert.equal(loaded.code, 's("bd")'); |
| 81 | assert.deepEqual(loaded.history, []); |
| 82 | }); |
| 83 | |
| 84 | test('malformed turns are dropped individually', async () => { |
| 85 | const file = path.join(DIR, 'partial.json'); |
| 86 | await fs.writeFile(file, JSON.stringify({ |
| 87 | code: 'x', |
| 88 | history: [{ role: 'user', content: 'ok' }, null, { role: 'user' }, 'nope'], |
| 89 | })); |
| 90 | const loaded = await sessions.load({ |
| 91 | name: 'partial', file, updatedAt: 0, hasHistory: true, |
| 92 | }); |
| 93 | assert.deepEqual(loaded.history, [{ role: 'user', content: 'ok' }]); |
| 94 | }); |
| 95 | |
| 96 | test('the most recently touched session is listed first', async () => { |
| 97 | await sessions.save('older', 'a', []); |
| 98 | await new Promise((r) => setTimeout(r, 10)); |
| 99 | await sessions.save('newer', 'b', []); |
| 100 | const list = await sessions.list(); |
| 101 | assert.equal(list[0]?.name, 'newer'); |
| 102 | }); |
| 103 | |
| 104 | test('listing an absent directory is empty, not an error', async () => { |
| 105 | const gone = path.join(DIR, 'nope'); |
| 106 | const loaded = await fs.readdir(gone).catch(() => null); |
| 107 | assert.equal(loaded, null); // precondition: really absent |
| 108 | // list() reads SESSIONS_DIR, which exists; the guarantee is that a missing |
| 109 | // directory yields [] rather than throwing, exercised on first run. |
| 110 | assert.ok(Array.isArray(await sessions.list())); |
| 111 | }); |