mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-09-11 11:18:36 +00:00
* feat(opentui): add the build, CI and parity tooling for the renderer migration Batch 7 of the ink→OpenTUI migration (#8662): everything the renderer needs to be built, verified and shipped — ink stays the default. Bundled builds silently lose code-block syntax highlighting because @opentui/core resolves its tree-sitter assets package-relatively, which misses inside the esbuild bundle. Relocate the runtime assets (parser worker, grammar wasms/scm, web-tree-sitter, native render library) next to the bundle, and gate OTUI_ASSET_ROOT on the tree being complete for the running platform — @opentui/core throws on any missing key, so an incomplete relocation must fall back instead of half-configuring. Standalone archives gain a Bun runtime (the renderer needs bun:ffi) and per-target @opentui platform packages via the existing release packaging, with --runtime=node restoring classic packaging. The renderer matrix drives interactive E2E under node+ink and bun+opentui, but Batch 6's renderer gate silently falls back to ink on an unsupported runtime, so a green opentui leg could be a false green. QWEN_TUI_RENDERER_STRICT turns that fallback into a loud startup failure for the matrix legs only; user-facing default behavior is unchanged. Parity tooling: a tui-parity workflow with component snapshots and a no-flicker gate (zero full-screen clears, balanced DEC 2026) that runs offline without model credentials, an ink→opentui codemod with a self-test, the PTY/tmux comparison harnesses, and the cli scripts/ tree is now covered by the package typecheck. * fix(ci): build all packages in the tui-parity jobs With prepare skipped, npm ci leaves the CLI's workspace dependencies unbuilt, and a workspace-scoped build of the CLI alone resolves their types from a dist that does not exist yet. The parity jobs must run the repository-root build like the other E2E legs. * fix(build): enforce renderer strict mode and drop musl from standalone packages Address yiliang114's review on the Batch 7 build/CI work: - propagate QWEN_TUI_RENDERER_STRICT into RendererSelection so the dispatcher also fails loudly when the OpenTUI entry returns false or throws, closing the false-green path in the E2E renderer matrix - stage the OpenTUI platform packages only for the bun runtime and drop the -musl variants: the bundled Bun binaries are glibc-linked and cannot start on musl hosts, so the musl render packages claimed support the archives cannot deliver - wipe dist/opentui-assets before re-copying so a stale tree from an earlier bundle cannot satisfy the key-existence runtime gate - route the script-PTY capture branch through spawnScriptPty so the argv form follows the platform, and give it the destroy() that finish() expects from the node-pty-like interface - harden pty-e2e.sh (set -euo pipefail, mktemp+trap, full TCL escaping of the prompt) and tmux-compare.sh (missing-binary check, mktemp+trap) - pin the CI Bun setups to 1.3.14 = DEFAULT_BUN_VERSION so an upstream Bun release cannot flip the gating legs on its own * fix(build): keep classic Node packaging as the standalone default The temporary OpenTUI preview flavor (--runtime=bun) stays available but no longer replaces the default release archives, keeping Batch 7 additive for existing standalone users. * fix(scripts): compare prettier-normalized output in the codemod self-test Address F1 and F3 from the deep verification report: - The self-test compared the codemod's raw output against the prettier-formatted fixtures/after.tsx, so 2 of 17 tests failed as committed. Normalize both sides through the repo prettier config before comparing (tests 1 and 6); the harness awaits because prettier 3's format() is async-only. - Refresh the tui-parity.yml baseline entry from 2190 to the measured 2508 bytes so the ratchet tracks real growth. * fix(build): gate standalone opentui assets by runtime flavor Node archives carry no opentui-assets tree at all: the renderer needs bun:ffi, and cross-built archives would only hold another platform's native library that the all-or-nothing asset gate could never activate. Bun archives now prune every @opentui/core-* directory that does not belong to the build target, dropping the build host's library from the archive. The npm-package allowlist comment is corrected to match the observed fallback behavior. * test(cli): cover the OpenTUI strict-mode dispatch and e2e renderer matrix main() now has coverage for the four dispatch outcomes: strict mode throws both when the OpenTUI entry declines to start and when it boots with an error, and non-strict mode falls through to startInteractiveUI. resolveE2eCliCommand gains coverage for the opentui leg — bun on PATH resolves to bun, and a missing bun fails with the actionable error.
201 lines
6.8 KiB
JavaScript
201 lines
6.8 KiB
JavaScript
// 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 = <Box {...rest} padding={1}>hi</Box>;';
|
|
const res = transformSource(src);
|
|
assert.equal(res.output, 'const x = <box {...rest} padding={1}>hi</box>;');
|
|
assert.ok(res.notes.some((nt) => /spread/.test(nt.msg)));
|
|
});
|
|
|
|
await test('manual: malformed attribute leaves file unchanged', () => {
|
|
const src = 'const x = <Box padding=1>bad</Box>;';
|
|
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 = <Box style={{ ...base }} padding={1}>hi</Box>;';
|
|
const res = transformSource(src);
|
|
assert.equal(
|
|
res.output,
|
|
'const x = <box style={{ ...base }} padding={1}>hi</box>;',
|
|
);
|
|
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 = <Box style={{ padding: 4 }} padding={1}>x</Box>;';
|
|
const res = transformSource(src);
|
|
assert.equal(
|
|
res.output,
|
|
'const x = <box style={{ padding: 4 }} padding={1}>x</box>;',
|
|
);
|
|
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 = <Box style={baseStyle} padding={1}>x</Box>;';
|
|
const res = transformSource(src);
|
|
assert.equal(
|
|
res.output,
|
|
'const x = <box style={baseStyle} padding={1}>x</box>;',
|
|
);
|
|
assert.ok(res.notes.length >= 1);
|
|
});
|
|
|
|
await test('manual: mismatched closing tag leaves file unchanged', () => {
|
|
const src = 'const x = <Box>a</Text>;';
|
|
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<Box>(null);',
|
|
'const v = <div className="a"><span>hi</span></div>;',
|
|
'const s = "<Box>not jsx</Box>";',
|
|
'',
|
|
].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 = <Box>x</Box>; const b = <Text>y</Text>; const half = n / 2;';
|
|
const res = transformSource(src);
|
|
assert.equal(
|
|
res.output,
|
|
'const a = <box>x</box>; const b = <text>y</text>; 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 = <Box margin="a\\b" padding={1}>x</Box>';
|
|
const res = transformSource(src);
|
|
assert.equal(res.output, 'const x = <box margin="a\\b" padding={1}>x</box>');
|
|
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 = <Box margin="1 2">x</Box>';
|
|
const res = transformSource(src);
|
|
assert.equal(res.output, 'const x = <box margin="1 2">x</box>');
|
|
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`);
|