// Self-test for ink-to-opentui.mjs. Run with: node scripts/codemod/codemod.test.mjs import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { format } from 'prettier'; import { transformSource } from './ink-to-opentui.mjs'; const here = dirname(fileURLToPath(import.meta.url)); const codemod = join(here, 'ink-to-opentui.mjs'); const before = readFileSync(join(here, 'fixtures', 'before.tsx'), 'utf8'); const after = readFileSync(join(here, 'fixtures', 'after.tsx'), 'utf8'); const config = JSON.parse( readFileSync(join(here, '..', '..', '.prettierrc.json'), 'utf8'), ); // The fixture is the codemod output plus prettier formatting, so compare after // normalizing both sides (prettier 3's format() is async-only). const normalize = (src) => format(src, { ...config, parser: 'typescript' }); let failures = 0; let count = 0; async function test(name, fn) { count++; try { await fn(); console.log(`ok ${count} - ${name}`); } catch (err) { failures++; console.error(`FAIL ${count} - ${name}`); console.error(err && err.message ? err.message : err); } } function runCli(args) { return spawnSync(process.execPath, [codemod, ...args], { encoding: 'utf8' }); } await test('fixture: transform matches after.tsx', async () => { const res = transformSource(before); assert.equal(res.changed, true); assert.equal(await normalize(res.output), await normalize(after)); assert.equal(res.notes.length, 0); }); await test('fixture: idempotent on after.tsx', () => { const res = transformSource(after); assert.equal(res.changed, false); assert.equal(res.output, after); }); await test('fixture: stats count renamed elements and collected props', () => { const res = transformSource(before); assert.equal(res.stats.box, 5); assert.equal(res.stats.text, 3); assert.equal(res.stats.propsCollected, 12); assert.equal(res.stats.styleTags, 5); }); const tmpDir = join(here, '.tmp-test'); mkdirSync(tmpDir, { recursive: true }); const tmpFile = join(tmpDir, 'sample.tsx'); try { await test('cli: default is dry-run and writes nothing', () => { writeFileSync(tmpFile, before); const r = runCli([tmpFile]); assert.equal(r.status, 0, r.stderr); assert.match(r.stdout, /\[dry-run\]/); assert.match(r.stdout, /dry-run, nothing written/); assert.equal(readFileSync(tmpFile, 'utf8'), before); }); await test('cli: --dry-run writes nothing', () => { writeFileSync(tmpFile, before); const r = runCli(['--dry-run', tmpFile]); assert.equal(r.status, 0, r.stderr); assert.equal(readFileSync(tmpFile, 'utf8'), before); }); await test('cli: --apply rewrites to fixture after.tsx', async () => { writeFileSync(tmpFile, before); const r = runCli(['--apply', tmpFile]); assert.equal(r.status, 0, r.stderr); assert.match(r.stdout, /\[apply\]/); assert.match(r.stdout, /written/); assert.equal( await normalize(readFileSync(tmpFile, 'utf8')), await normalize(after), ); }); await test('cli: directory input is scanned', () => { writeFileSync(tmpFile, before); const r = runCli(['--dry-run', tmpDir]); assert.equal(r.status, 0, r.stderr); assert.match(r.stdout, /sample\.tsx/); assert.equal(readFileSync(tmpFile, 'utf8'), before); }); } finally { rmSync(tmpDir, { recursive: true, force: true }); } await test('manual: spread attribute keeps props, still renames', () => { const src = 'const x = hi;'; const res = transformSource(src); assert.equal(res.output, 'const x = hi;'); assert.ok(res.notes.some((nt) => /spread/.test(nt.msg))); }); await test('manual: malformed attribute leaves file unchanged', () => { const src = 'const x = bad;'; const res = transformSource(src); assert.equal(res.output, src); assert.equal(res.changed, false); assert.equal(res.notes.length, 1); }); await test('manual: existing style with spread renames only', () => { const src = 'const x = hi;'; const res = transformSource(src); assert.equal( res.output, 'const x = hi;', ); assert.ok( res.notes.some((nt) => /spread inside existing style object/.test(nt.msg)), ); }); await test('manual: conflicting key in existing style object', () => { const src = 'const x = x;'; const res = transformSource(src); assert.equal( res.output, 'const x = x;', ); assert.ok(res.notes.some((nt) => /already present/.test(nt.msg))); }); await test('manual: non-object style expression is not merged', () => { const src = 'const x = x;'; const res = transformSource(src); assert.equal( res.output, 'const x = x;', ); assert.ok(res.notes.length >= 1); }); await test('manual: mismatched closing tag leaves file unchanged', () => { const src = 'const x = a;'; const res = transformSource(src); assert.equal(res.output, src); assert.ok(res.notes.length >= 1); }); await test('ignore: generics, foreign tags and strings untouched', () => { const src = [ 'const r = useRef(null);', 'const v =
hi
;', 'const s = "not jsx";', '', ].join('\n'); const res = transformSource(src); assert.equal(res.changed, false); assert.equal(res.output, src); }); await test('regex mask: closing-tag slash is not a regex start', () => { const src = 'const a = x; const b = y; const half = n / 2;'; const res = transformSource(src); assert.equal( res.output, 'const a = x; const b = y; const half = n / 2;', ); assert.equal(res.stats.text, 1); assert.equal(res.notes.length, 0); }); await test('manual: string style value with backslash is not copied verbatim', () => { const src = 'const x = x'; const res = transformSource(src); assert.equal(res.output, 'const x = x'); assert.ok(res.notes.some((nt) => /escape\/entity semantics/.test(nt.msg))); }); await test('manual: string style value with HTML entity is not copied verbatim', () => { const src = 'const x = x'; const res = transformSource(src); assert.equal(res.output, 'const x = x'); assert.ok(res.notes.some((nt) => /escape\/entity semantics/.test(nt.msg))); }); if (failures > 0) { console.error(`# ${failures}/${count} test(s) failed`); process.exit(1); } console.log(`# ${count}/${count} tests passed`);