// 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`);