From 357c1d11c5ce1ff097e0ff962cd10763c6b4eb08 Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Tue, 28 Apr 2026 22:19:51 +0200 Subject: [PATCH] tui: wire approve dispatch in bin entry phobiCS-tui dispatcher: - --version / -v: print version, exit 0 - approve : read both files, render , exit 0 on accept / 1 on reject / 2 on bad args or read error - no args: print 'browser mode coming' placeholder, exit 0 - SIGTERM/SIGINT during approve: unmount + exit 1 Integration tests cover the no-TTY paths (--version, missing file, missing args). Accept/reject keybinds are covered by the ink-testing-library tests in Approve.test.tsx; piping stdin into a no-TTY ink process is flaky on Windows so we don't try to integration-test that path. The shebang line is intentionally absent from the source; the build script prepends one to dist/tui/index.js. --- src/tui/index.tsx | 59 ++++++++++++++++++++++++++--- tests/tui/index.integration.test.ts | 42 ++++++++++++++++++++ 2 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 tests/tui/index.integration.test.ts diff --git a/src/tui/index.tsx b/src/tui/index.tsx index 7b99d3b..57e362a 100644 --- a/src/tui/index.tsx +++ b/src/tui/index.tsx @@ -1,12 +1,61 @@ import React from 'react'; import { render } from 'ink'; -import { Text } from 'ink'; +import * as fs from 'fs/promises'; +import { Approve, Decision } from './approve/Approve.js'; const argv = process.argv.slice(2); -if (argv[0] === '--version' || argv[0] === '-v') { - process.stdout.write('phobiCS-tui v0.1.0\n'); - process.exit(0); +async function main(): Promise { + if (argv[0] === '--version' || argv[0] === '-v') { + process.stdout.write('phobiCS-tui v0.1.0\n'); + return 0; + } + if (argv[0] === 'approve') { + return runApprove(argv[1], argv[2]); + } + process.stdout.write('phobiCS-tui — browser mode coming in a later task\n'); + return 0; } -render(phobiCS-tui — coming soon); +async function runApprove(oldPath: string | undefined, newPath: string | undefined): Promise { + if (!oldPath || !newPath) { + process.stderr.write('usage: phobiCS-tui approve \n'); + return 2; + } + let oldText: string; + let newText: string; + try { + oldText = await fs.readFile(oldPath, 'utf8'); + newText = await fs.readFile(newPath, 'utf8'); + } catch (err) { + process.stderr.write(`phobiCS-tui: ${(err as Error).message}\n`); + return 2; + } + + return new Promise((resolve) => { + const onDecision = (d: Decision) => { + app.unmount(); + resolve(d === 'accept' ? 0 : 1); + }; + const fileName = oldPath.split(/[/\\]/).pop() ?? oldPath; + const app = render( + + ); + + process.on('SIGTERM', () => { + app.unmount(); + resolve(1); + }); + process.on('SIGINT', () => { + app.unmount(); + resolve(1); + }); + }); +} + +main() + .then((code) => process.exit(code)) + .catch((err) => { + process.stderr.write(`phobiCS-tui: ${err}\n`); + process.exit(2); + }); diff --git a/tests/tui/index.integration.test.ts b/tests/tui/index.integration.test.ts new file mode 100644 index 0000000..76aeb2e --- /dev/null +++ b/tests/tui/index.integration.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { spawn } from 'child_process'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +const BIN = path.resolve('dist/tui/index.js'); + +beforeAll(async () => { + await fs.access(BIN); +}); + +function run(args: string[]): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise((resolve) => { + const child = spawn(process.execPath, [BIN, ...args], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d) => (stdout += d.toString())); + child.stderr.on('data', (d) => (stderr += d.toString())); + child.on('exit', (code) => resolve({ code: code ?? -1, stdout, stderr })); + }); +} + +describe('phobiCS-tui (integration, no-TTY paths only)', () => { + it('--version prints version and exits 0', async () => { + const r = await run(['--version']); + expect(r.code).toBe(0); + expect(r.stdout).toMatch(/phobiCS-tui v\d/); + }); + + it('approve with missing file exits 2', async () => { + const r = await run(['approve', '/nonexistent/old.st', '/nonexistent/new.st']); + expect(r.code).toBe(2); + }); + + it('approve with no args exits 2 with usage on stderr', async () => { + const r = await run(['approve']); + expect(r.code).toBe(2); + expect(r.stderr).toMatch(/usage:/); + }); +});