Skip to content

Commit f30dbd0

Browse files
Merge pull request #74 from foundersandcoders/fix/vitest-tui-suite-compat
Get TUI Tests Running Under Vitest
2 parents 466e213 + 19ec2ab commit f30dbd0

15 files changed

Lines changed: 323 additions & 23 deletions

docs/roadmaps/v5a-2606/enhanced.md

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -91,21 +91,38 @@ because the shell rollout and signature features build on them.
9191
test:all` still exits non-zero, but only because of the pre-existing
9292
TUI/vitest incompatibility tracked as **TR.A6** below — unrelated to
9393
storage and out of scope for this task.
94-
- [ ] **TR.A6** `fix/vitest-tui-suite-compat` — All 12 `tests/tui/**` suites
95-
fail to *load* under `vitest` (0 tests collected, not test failures):
96-
`tests/tui/app.test.ts` throws `ReferenceError: Cannot access
97-
'__vi_import_1__' before initialization` (a `vi.mock` factory hoisting
98-
issue); the other 11 (`theme`, `appShell`, `panel`, `keymap`,
99-
`dashboard`, `file-picker`, `history`, `mapping-builder`,
100-
`mapping-editor`, `mapping-save`, `settings`) all fail with
101-
`TypeError: Unknown file extension ".scm"` — vitest's Node loader
102-
chokes on `@opentui/core`'s bundled `highlights.scm` tree-sitter
103-
asset. Confirmed still present after TR.A5 (Bun/Node storage split)
104-
landed, so it's a separate blocker. **Fix:** either exclude
105-
`tests/tui/**` from the vitest config (formalising what TR.A4 already
106-
decided — these stay `bun test`-only) or resolve the `.scm` loader
107-
and hoisting issues so they run under both. Blocks a single unified
108-
green `bun run test:all`; does not block any Phase A-E branch.
94+
- [x] **TR.A6** `fix/vitest-tui-suite-compat` — All 12 `tests/tui/**` suites
95+
failed to *load* under `vitest` (0 tests collected, not test failures):
96+
a `vi.mock` factory hoisting `ReferenceError` in `app.test.ts`, plus
97+
`TypeError: Unknown file extension ".scm"` in the other 11 —
98+
`@opentui/core`'s bundled `highlights.scm`/`.wasm` tree-sitter assets,
99+
Bun-only `with { type: "file" }` imports vitest's Node loader can't
100+
resolve. **Investigated and rejected:** stubbing the `.scm`/`.wasm`
101+
imports via a Vite `load` plugin worked, but inlining `@opentui/core`
102+
(required so the plugin's transform applies) surfaced a harder wall —
103+
the same monolithic bundled chunk
104+
(`node_modules/@opentui/core/index-h3dbfsf6.js`) has unconditional
105+
top-level `import ... from "bun:ffi"` (the native Zig renderer
106+
binding). `bun:ffi` has no Node polyfill, so the real package cannot
107+
load under vitest at all, full stop — not a config problem. The
108+
official `@opentui/core/testing` subpath doesn't help either; it
109+
re-imports the same chunk. **Actual fix:** a hand-written shared test
110+
double, `tests/fixtures/tui/opentui.ts` — real named classes
111+
(`BoxRenderable`, `TextRenderable`, `SelectRenderable`, `RGBA`, etc.)
112+
matching opentui's actual behaviour where suites assert on it
113+
(constructor names, `RGBA.fromHex`/`.equals()` channel maths mirrored
114+
from opentui's real `hexToRgb`, `TextRenderable.content` string→
115+
StyledText coercion, colour-option coercion on both construction and
116+
reassignment). Each suite adds a one-line, hoisting-safe
117+
`vi.mock('@opentui/core', async () => import('.../opentui'))`
118+
the async factory only calls `import()` and closes over nothing, so
119+
it's immune to the original hoisting trap. Also needed
120+
`test.server.deps.inline: ['opentui-spinner']` in `vite.config.ts`:
121+
that package's own nested `@opentui/core` import is externalised by
122+
default, which bypasses `vi.mock` entirely unless inlined. **Done:**
123+
`bunx vitest run` — 53 files, 596 tests green (14 TUI files, 134
124+
tests); `bun run test:core` — 474 pass, unaffected. `bun run
125+
test:all` is unified green.
109126

110127
## Phase S — Security & dependency maintenance
111128

tests/fixtures/tui/opentui.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import { vi } from 'vitest';
2+
import { createMockRenderer } from './tui';
3+
4+
/**
5+
* Test double for `@opentui/core`.
6+
*
7+
* The real package bundles its tree-sitter grammars AND its native Zig
8+
* renderer bindings (`bun:ffi`) into one eagerly-evaluated chunk, so it can
9+
* only ever load under Bun — there is no way to import any symbol from it
10+
* under vitest/Node. Suites `vi.mock('@opentui/core', ...)` this module
11+
* instead of the real package.
12+
*
13+
* Fidelity is scoped to what tests/tui/** actually observes: constructor
14+
* names, constructor-options-become-properties (including on later
15+
* reassignment), child-list management, the string→StyledText coercion on
16+
* TextRenderable.content, and RGBA channel maths (mirrored from the real
17+
* hexToRgb so `.equals()`/`toEqual` behave identically to opentui).
18+
*/
19+
20+
/**
21+
* Colour-shaped option keys opentui coerces from hex string to RGBA, both at
22+
* construction and on later reassignment (e.g. panel.ts's box.borderColor =
23+
* theme.accent). Defined as accessors on the prototype so every set — not
24+
* just the constructor's initial assignment — goes through parseColor.
25+
*/
26+
const COLOUR_KEYS = ['backgroundColor', 'borderColor', 'fg', 'bg', 'color', 'selectedBackgroundColor'];
27+
28+
class BaseRenderable {
29+
id: string;
30+
protected colourValues: Record<string, unknown> = {};
31+
private children: BaseRenderable[] = [];
32+
33+
constructor(_renderer: unknown, options: Record<string, unknown> = {}) {
34+
this.id = (options.id as string) ?? `mock-${Math.random().toString(36).slice(2)}`;
35+
Object.assign(this, options);
36+
}
37+
38+
add(child: BaseRenderable): void {
39+
this.children.push(child);
40+
}
41+
42+
remove(id: string): void {
43+
this.children = this.children.filter((child) => child.id !== id);
44+
}
45+
46+
insertBefore(child: BaseRenderable, beforeId: string): void {
47+
const index = this.children.findIndex((existing) => existing.id === beforeId);
48+
if (index === -1) {
49+
this.children.push(child);
50+
} else {
51+
this.children.splice(index, 0, child);
52+
}
53+
}
54+
55+
getChildren(): BaseRenderable[] {
56+
return this.children;
57+
}
58+
}
59+
60+
for (const key of COLOUR_KEYS) {
61+
Object.defineProperty(BaseRenderable.prototype, key, {
62+
configurable: true,
63+
enumerable: true,
64+
get(this: BaseRenderable) {
65+
return this.colourValues[key];
66+
},
67+
set(this: BaseRenderable, value: unknown) {
68+
this.colourValues[key] = typeof value === 'string' ? parseColor(value) : value;
69+
},
70+
});
71+
}
72+
73+
/** Base class opentui-spinner's SpinnerRenderable extends — construction only, no render loop. */
74+
export class Renderable extends BaseRenderable {}
75+
76+
export class BoxRenderable extends BaseRenderable {}
77+
78+
/** Mirrors opentui: assigning a string to `.content` wraps it as StyledText. */
79+
export class TextRenderable extends BaseRenderable {
80+
private _content: { chunks: { text: string }[] };
81+
82+
constructor(renderer: unknown, options: Record<string, unknown> = {}) {
83+
const { content, ...rest } = options;
84+
super(renderer, rest);
85+
this._content = TextRenderable.toStyledText(content);
86+
}
87+
88+
get content(): { chunks: { text: string }[] } {
89+
return this._content;
90+
}
91+
92+
set content(value: unknown) {
93+
this._content = TextRenderable.toStyledText(value);
94+
}
95+
96+
private static toStyledText(value: unknown): { chunks: { text: string }[] } {
97+
if (value && typeof value === 'object' && 'chunks' in value) {
98+
return value as { chunks: { text: string }[] };
99+
}
100+
return { chunks: [{ text: (value as string) ?? '' }] };
101+
}
102+
}
103+
104+
export const SelectRenderableEvents = {
105+
ITEM_SELECTED: 'itemSelected',
106+
SELECTION_CHANGED: 'selectionChanged',
107+
} as const;
108+
109+
export const InputRenderableEvents = {
110+
INPUT: 'input',
111+
ENTER: 'enter',
112+
} as const;
113+
114+
export class SelectRenderable extends BaseRenderable {
115+
on = vi.fn();
116+
once = vi.fn();
117+
focus = vi.fn();
118+
setSelectedIndex = vi.fn();
119+
selectCurrent = vi.fn();
120+
}
121+
122+
export class InputRenderable extends BaseRenderable {
123+
on = vi.fn();
124+
focus = vi.fn();
125+
}
126+
127+
export class TabSelectRenderable extends BaseRenderable {
128+
on = vi.fn();
129+
focus = vi.fn();
130+
moveLeft = vi.fn();
131+
moveRight = vi.fn();
132+
}
133+
134+
export class ASCIIFontRenderable extends BaseRenderable {}
135+
136+
/**
137+
* Mirrors opentui's real RGBA: a Float32Array-backed colour with r/g/b/a
138+
* getters. Reproducing the real hexToRgb maths (not just a distinct fake
139+
* shape) means two independent `RGBA.fromHex(x)` calls satisfy `toEqual`
140+
* structural equality, matching the real assertions in theme.test.ts.
141+
*/
142+
export class RGBA {
143+
buffer: Float32Array;
144+
145+
constructor(buffer: Float32Array) {
146+
this.buffer = buffer;
147+
}
148+
149+
static fromHex(hex: string): RGBA {
150+
let clean = hex.replace(/^#/, '');
151+
if (clean.length === 3) {
152+
clean = clean[0] + clean[0] + clean[1] + clean[1] + clean[2] + clean[2];
153+
}
154+
const r = parseInt(clean.substring(0, 2), 16) / 255;
155+
const g = parseInt(clean.substring(2, 4), 16) / 255;
156+
const b = parseInt(clean.substring(4, 6), 16) / 255;
157+
const a = clean.length === 8 ? parseInt(clean.substring(6, 8), 16) / 255 : 1;
158+
return new RGBA(new Float32Array([r, g, b, a]));
159+
}
160+
161+
get r(): number {
162+
return this.buffer[0];
163+
}
164+
get g(): number {
165+
return this.buffer[1];
166+
}
167+
get b(): number {
168+
return this.buffer[2];
169+
}
170+
get a(): number {
171+
return this.buffer[3];
172+
}
173+
174+
equals(other: RGBA | undefined | null): boolean {
175+
if (!other) return false;
176+
return this.r === other.r && this.g === other.g && this.b === other.b && this.a === other.a;
177+
}
178+
}
179+
180+
/** Mirrors opentui's real parseColor: strings become RGBA, everything else passes through. */
181+
export function parseColor(color: unknown): unknown {
182+
if (typeof color === 'string') {
183+
if (color.toLowerCase() === 'transparent') {
184+
return new RGBA(new Float32Array([0, 0, 0, 0]));
185+
}
186+
return RGBA.fromHex(color);
187+
}
188+
return color;
189+
}
190+
191+
/**
192+
* The real resolveRenderLib() loads the native Zig binding via bun:ffi — not
193+
* reproducible under Node. Nothing in tests/tui/** constructs a renderable
194+
* that calls into it at runtime (opentui-spinner's SpinnerRenderable is only
195+
* ever imported, never instantiated, by the suites here), so a throwing stub
196+
* is correct: it surfaces loudly if that assumption ever changes.
197+
*/
198+
export function resolveRenderLib(): never {
199+
throw new Error(
200+
'resolveRenderLib() is not mocked — tests must not instantiate renderables that need the native render lib.'
201+
);
202+
}
203+
204+
/** Styled-text helpers (assets/brand + about.ts) — simple passthroughs. */
205+
export function t(strings: TemplateStringsArray, ...values: unknown[]): { chunks: { text: string }[] } {
206+
const text = strings.reduce((acc, str, i) => acc + str + (values[i] ?? ''), '');
207+
return { chunks: [{ text }] };
208+
}
209+
210+
export function fg(_color: unknown) {
211+
return (text: string) => text;
212+
}
213+
214+
export function link(_url: string) {
215+
return (text: string) => text;
216+
}
217+
218+
export function underline(text: string): string {
219+
return text;
220+
}
221+
222+
export async function createCliRenderer(_options?: unknown) {
223+
return createMockRenderer();
224+
}

tests/tui/app.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2-
import { TUI } from '../../src/tui/app';
3-
import { createMockRenderer } from '../fixtures/tui/tui';
42

5-
vi.mock('@opentui/core', () => ({
6-
createCliRenderer: vi.fn().mockResolvedValue(createMockRenderer()),
7-
}));
3+
// @opentui/core can only load under Bun (see tests/fixtures/tui/opentui.ts),
4+
// so it's replaced with a shared test double. The factory only calls import()
5+
// and closes over nothing, so it's safe under vi.mock's hoisting.
6+
vi.mock('@opentui/core', async () => import('../fixtures/tui/opentui'));
7+
8+
import { TUI } from '../../src/tui/app';
89

910
describe('TUI', () => {
1011
let mockExit: any;

tests/tui/components/appShell.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
import { describe, it, expect, beforeEach } from 'vitest';
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
22
import { appShell } from '../../../src/tui/components/appShell';
33
import { APP_VERSION } from '../../../src/tui/utils/layout';
44
import * as fixtures from '../../fixtures/tui/tui';
55

6+
// @opentui/core can only load under Bun (see tests/fixtures/tui/opentui.ts),
7+
// so it's replaced with a shared test double.
8+
vi.mock('@opentui/core', async () => import('../../fixtures/tui/opentui'));
9+
610
describe('appShell()', () => {
711
let ctx: ReturnType<typeof fixtures.createMockContext>;
812

tests/tui/components/panel.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { describe, it, expect, beforeEach } from 'vitest';
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
3+
// @opentui/core can only load under Bun (see tests/fixtures/tui/opentui.ts),
4+
// so it's replaced with a shared test double.
5+
vi.mock('@opentui/core', async () => import('../../fixtures/tui/opentui'));
6+
27
import { RGBA } from '@opentui/core';
38
import { panel } from '../../../src/tui/components/panel';
49
import { theme } from '../../../assets/brand/theme';

tests/tui/screens/dashboard.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
22
import { Dashboard } from '../../../src/tui/screens/dashboard';
33
import * as fixtures from '../../fixtures/tui/tui';
44

5+
// @opentui/core can only load under Bun (see tests/fixtures/tui/opentui.ts),
6+
// so it's replaced with a shared test double.
7+
vi.mock('@opentui/core', async () => import('../../fixtures/tui/opentui'));
8+
59
// Mock storage so render() can load config without hitting the filesystem
610
vi.mock('../../../src/lib/storage', () => ({
711
createStorage: () => ({

tests/tui/screens/file-picker.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import * as tuiFixtures from '../../fixtures/tui/tui';
44
import * as filePickerFixtures from '../../fixtures/tui/screens/file-picker';
55
import fs from 'node:fs/promises';
66

7+
// @opentui/core can only load under Bun (see tests/fixtures/tui/opentui.ts),
8+
// so it's replaced with a shared test double.
9+
vi.mock('@opentui/core', async () => import('../../fixtures/tui/opentui'));
10+
711
vi.mock('node:fs/promises', () => ({
812
default: {
913
readdir: vi.fn(),

tests/tui/screens/history.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
33
import { HistoryScreen } from '../../../src/tui/screens/history';
44
import * as fixtures from '../../fixtures/tui/tui';
55

6+
// @opentui/core can only load under Bun (see tests/fixtures/tui/opentui.ts),
7+
// so it's replaced with a shared test double.
8+
vi.mock('@opentui/core', async () => import('../../fixtures/tui/opentui'));
9+
610
// Mock storage so render() can load history without hitting the filesystem
711
vi.mock('../../../src/lib/storage', () => ({
812
createStorage: () => ({

tests/tui/screens/mapping-builder.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
22
import { MappingBuilderScreen } from '../../../src/tui/screens/mapping-builder';
33
import * as fixtures from '../../fixtures/tui/tui';
44

5+
// @opentui/core can only load under Bun (see tests/fixtures/tui/opentui.ts),
6+
// so it's replaced with a shared test double.
7+
vi.mock('@opentui/core', async () => import('../../fixtures/tui/opentui'));
8+
59
// Mock createStorage — include ALL methods to avoid leaking incomplete mocks
610
vi.mock('../../../src/lib/storage', () => ({
711
createStorage: () => ({

tests/tui/screens/mapping-editor.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
22
import { MappingEditorScreen } from '../../../src/tui/screens/mapping-editor';
33
import * as fixtures from '../../fixtures/tui/tui';
44

5+
// @opentui/core can only load under Bun (see tests/fixtures/tui/opentui.ts),
6+
// so it's replaced with a shared test double.
7+
vi.mock('@opentui/core', async () => import('../../fixtures/tui/opentui'));
8+
59
// Mock createStorage — include ALL methods to avoid leaking incomplete mocks
610
vi.mock('../../../src/lib/storage', () => ({
711
createStorage: () => ({

0 commit comments

Comments
 (0)