// SPDX-License-Identifier: MIT // Run beside task_report.mjs: node --test task_report.test.mjs import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; import { summarise, MAX_BYTES, MAX_RECORDS } from './task_report.mjs'; const source = fileURLToPath(new URL('./task_report.mjs', import.meta.url)); const encode = value => Buffer.from(JSON.stringify(value)); const task = (title = 'Read', status = 'open') => ({ title, status }); function runFile(raw) { const folder = mkdtempSync(join(tmpdir(), 'openvist-js-')); try { const file = join(folder, 'tasks.json'); writeFileSync(file, raw); return spawnSync(process.execPath, [source, file], { encoding: 'utf8', timeout: 5000 }); } finally { rmSync(folder, { recursive: true, force: true }); } } function invalid(raw) { const result = runFile(raw); assert.equal(result.status, 2, result.stderr); assert.equal(result.stdout, ''); assert.equal(result.stderr, 'error: invalid or unreadable task file\n'); } test('ordinary mixed input', () => { const result = runFile(encode([task(), task('Test', 'closed'), task('Review')])); assert.equal(result.status, 0, result.stderr); assert.equal(result.stderr, ''); assert.deepEqual(JSON.parse(result.stdout), { by_status: { closed: 1, open: 2 }, records: 3 }); }); test('empty input list', () => { assert.deepEqual(summarise([]), { by_status: { closed: 0, open: 0 }, records: 0 }); }); test('malformed JSON', () => invalid(Buffer.from('['))); test('wrong root type', () => invalid(Buffer.from('{}'))); test('non-object record', () => invalid(encode([1]))); test('null record', () => invalid(encode([null]))); test('array record', () => invalid(encode([[]]))); test('invalid last record produces no partial output', () => invalid(encode([task(), task('Bad', 'pending')]))); test('missing and extra fields', () => { invalid(encode([{ title: 'Read' }])); invalid(encode([{ ...task(), extra: true }])); }); test('status must be exact lowercase text', () => { for (const status of ['Open', 'pending', 'open\u2028', 'open\u2029', true, 1, null, [], {}]) invalid(encode([task('Read', status)])); }); test('title must be nonblank text', () => { for (const title of ['', ' \t', null, 1]) invalid(encode([task(title)])); }); test('title code-point boundary', () => { assert.equal(runFile(encode([task('😀'.repeat(100))])).status, 0); invalid(encode([task('😀'.repeat(101))])); }); test('invalid UTF-8 rejected', () => invalid(Buffer.from([0xff]))); test('BOM rejected', () => invalid(Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('[]')]))); test('exact byte limit and one more byte', () => { assert.equal(runFile(Buffer.from('[]' + ' '.repeat(MAX_BYTES - 2))).status, 0); invalid(Buffer.from('[]' + ' '.repeat(MAX_BYTES - 1))); }); test('record limit and one more record', () => { const valid = encode(Array.from({ length: MAX_RECORDS }, () => task())); assert.ok(valid.length <= MAX_BYTES); assert.equal(runFile(valid).status, 0); invalid(encode(Array.from({ length: MAX_RECORDS + 1 }, () => task()))); }); test('nonstandard NaN and Infinity syntax rejected', () => { for (const raw of ['[NaN]', '[Infinity]', '[-Infinity]']) invalid(Buffer.from(raw)); }); test('duplicate keys deliberately use last value', () => { const result = runFile(Buffer.from('[{"title":"Read","status":"pending","status":"open"}]')); assert.equal(result.status, 0, result.stderr); assert.deepEqual(JSON.parse(result.stdout), { by_status: { closed: 0, open: 1 }, records: 1 }); }); test('prototype-named extra key rejected', () => { invalid(Buffer.from('[{"title":"Read","status":"open","__proto__":{}}]')); assert.equal(Object.prototype.polluted, undefined); }); test('missing file', () => { const folder = mkdtempSync(join(tmpdir(), 'openvist-js-missing-')); try { const result = spawnSync(process.execPath, [source, join(folder, 'missing.json')], { encoding: 'utf8', timeout: 5000 }); assert.equal(result.status, 2); assert.equal(result.stdout, ''); assert.equal(result.stderr, 'error: invalid or unreadable task file\n'); } finally { rmSync(folder, { recursive: true, force: true }); } }); test('help and argument count', () => { const help = spawnSync(process.execPath, [source, '--help'], { encoding: 'utf8', timeout: 5000 }); assert.equal(help.status, 0); assert.match(help.stdout, /Usage: node task_report\.mjs/); for (const args of [[], ['one.json', 'two.json']]) { const result = spawnSync(process.execPath, [source, ...args], { encoding: 'utf8', timeout: 5000 }); assert.equal(result.status, 2); assert.equal(result.stdout, ''); } }); test('import exposes functions without starting CLI', () => { assert.equal(typeof summarise, 'function'); assert.equal(summarise([task()]).records, 1); });