@gridland/testing 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,133 @@
1
+ import { ReactNode } from 'react';
2
+ import { EventEmitter } from 'events';
3
+
4
+ /**
5
+ * Minimal buffer interface — matches BrowserBuffer's public API.
6
+ * This avoids a hard dependency on @gridland/web for the testing utilities.
7
+ */
8
+ interface ReadableBuffer {
9
+ width: number;
10
+ height: number;
11
+ char: Uint32Array;
12
+ fg: Float32Array;
13
+ bg: Float32Array;
14
+ attributes: Uint32Array;
15
+ }
16
+ /**
17
+ * Screen provides query helpers for reading buffer content in tests.
18
+ * Unlike ink-testing which parses ANSI, we read TypedArrays directly.
19
+ */
20
+ declare class Screen {
21
+ private buffer;
22
+ private _frames;
23
+ constructor(buffer: ReadableBuffer);
24
+ /** Capture a frame snapshot (call after each render) */
25
+ captureFrame(): void;
26
+ /** Get the current screen text (plain chars, trailing spaces trimmed) */
27
+ text(): string;
28
+ /** Get raw text including all spaces (no trimming) */
29
+ rawText(): string;
30
+ /** Check if the screen contains the given text */
31
+ contains(text: string): boolean;
32
+ /** Check if the screen matches a regex */
33
+ matches(pattern: RegExp): boolean;
34
+ /** Get a specific line (0-indexed) */
35
+ line(n: number): string;
36
+ /** Get all non-empty lines */
37
+ lines(): string[];
38
+ /** Get all captured frames */
39
+ frames(): string[];
40
+ /** Get the number of columns */
41
+ get width(): number;
42
+ /** Get the number of rows */
43
+ get height(): number;
44
+ }
45
+
46
+ /**
47
+ * Minimal render context interface for key input.
48
+ */
49
+ interface KeyInputContext {
50
+ keyInput: EventEmitter;
51
+ _internalKeyInput: EventEmitter;
52
+ }
53
+ /**
54
+ * KeySender simulates keyboard input for testing.
55
+ */
56
+ declare class KeySender {
57
+ private ctx;
58
+ constructor(ctx: KeyInputContext);
59
+ private sendKey;
60
+ /** Type a string of text character by character */
61
+ type(text: string): void;
62
+ /** Press a single character key */
63
+ press(char: string): void;
64
+ /** Send raw data (for escape sequences etc.) */
65
+ raw(data: string): void;
66
+ enter(): void;
67
+ escape(): void;
68
+ tab(): void;
69
+ backspace(): void;
70
+ delete(): void;
71
+ space(): void;
72
+ up(): void;
73
+ down(): void;
74
+ left(): void;
75
+ right(): void;
76
+ home(): void;
77
+ end(): void;
78
+ pageUp(): void;
79
+ pageDown(): void;
80
+ }
81
+
82
+ interface WaitForOptions {
83
+ /** Timeout in ms (default: 3000) */
84
+ timeout?: number;
85
+ /** Polling interval in ms (default: 50) */
86
+ interval?: number;
87
+ }
88
+ /**
89
+ * Wait for a condition to be met on the screen.
90
+ *
91
+ * @param screen - The screen to poll
92
+ * @param condition - Either a string (wait for screen to contain it) or a function (wait for it to not throw)
93
+ * @param options - Timeout and interval settings
94
+ */
95
+ declare function waitFor(screen: Screen, condition: string | (() => void), options?: WaitForOptions): Promise<void>;
96
+
97
+ interface TuiInstance {
98
+ /** Screen queries — read buffer content */
99
+ screen: Screen;
100
+ /** Key input simulation */
101
+ keys: KeySender;
102
+ /** Wait for text or assertion */
103
+ waitFor: (condition: string | (() => void), options?: WaitForOptions) => Promise<void>;
104
+ /** Force a synchronous render cycle */
105
+ flush: () => void;
106
+ /** Re-render with new content */
107
+ rerender: (node: ReactNode) => void;
108
+ /** Unmount and clean up */
109
+ unmount: () => void;
110
+ }
111
+ interface RenderTuiOptions {
112
+ /** Number of columns (default: 80) */
113
+ cols?: number;
114
+ /** Number of rows (default: 24) */
115
+ rows?: number;
116
+ }
117
+ /**
118
+ * Render a Gridland component for testing.
119
+ *
120
+ * Note: This function requires @opentui/core and the gridland-web browser runtime
121
+ * to be available. It works in test environments that have the opentui monorepo
122
+ * accessible and proper module resolution configured.
123
+ *
124
+ * For simpler testing (Screen, Keys, waitFor), use those classes directly
125
+ * with a BrowserBuffer — they have no external dependencies.
126
+ */
127
+ declare function renderTui(node: ReactNode, options?: RenderTuiOptions): TuiInstance;
128
+ /**
129
+ * Clean up all active test instances. Call in afterEach().
130
+ */
131
+ declare function cleanup(): void;
132
+
133
+ export { KeySender, type RenderTuiOptions, Screen, type TuiInstance, type WaitForOptions, cleanup, renderTui, waitFor };
package/dist/index.js ADDED
@@ -0,0 +1,361 @@
1
+ // src/screen.ts
2
+ var Screen = class {
3
+ buffer;
4
+ _frames = [];
5
+ constructor(buffer) {
6
+ this.buffer = buffer;
7
+ }
8
+ /** Capture a frame snapshot (call after each render) */
9
+ captureFrame() {
10
+ this._frames.push(this.text());
11
+ }
12
+ /** Get the current screen text (plain chars, trailing spaces trimmed) */
13
+ text() {
14
+ const lines = [];
15
+ for (let row = 0; row < this.buffer.height; row++) {
16
+ let line = "";
17
+ for (let col = 0; col < this.buffer.width; col++) {
18
+ const idx = row * this.buffer.width + col;
19
+ const charCode = this.buffer.char[idx];
20
+ line += charCode === 0 ? " " : String.fromCodePoint(charCode);
21
+ }
22
+ lines.push(line.trimEnd());
23
+ }
24
+ while (lines.length > 0 && lines[lines.length - 1] === "") {
25
+ lines.pop();
26
+ }
27
+ return lines.join("\n");
28
+ }
29
+ /** Get raw text including all spaces (no trimming) */
30
+ rawText() {
31
+ const lines = [];
32
+ for (let row = 0; row < this.buffer.height; row++) {
33
+ let line = "";
34
+ for (let col = 0; col < this.buffer.width; col++) {
35
+ const idx = row * this.buffer.width + col;
36
+ const charCode = this.buffer.char[idx];
37
+ line += charCode === 0 ? " " : String.fromCodePoint(charCode);
38
+ }
39
+ lines.push(line);
40
+ }
41
+ return lines.join("\n");
42
+ }
43
+ /** Check if the screen contains the given text */
44
+ contains(text) {
45
+ return this.text().includes(text);
46
+ }
47
+ /** Check if the screen matches a regex */
48
+ matches(pattern) {
49
+ return pattern.test(this.text());
50
+ }
51
+ /** Get a specific line (0-indexed) */
52
+ line(n) {
53
+ const lines = this.text().split("\n");
54
+ return lines[n] ?? "";
55
+ }
56
+ /** Get all non-empty lines */
57
+ lines() {
58
+ return this.text().split("\n").filter((l) => l.length > 0);
59
+ }
60
+ /** Get all captured frames */
61
+ frames() {
62
+ return [...this._frames];
63
+ }
64
+ /** Get the number of columns */
65
+ get width() {
66
+ return this.buffer.width;
67
+ }
68
+ /** Get the number of rows */
69
+ get height() {
70
+ return this.buffer.height;
71
+ }
72
+ };
73
+
74
+ // src/keys.ts
75
+ var KeySender = class {
76
+ ctx;
77
+ constructor(ctx) {
78
+ this.ctx = ctx;
79
+ }
80
+ sendKey(name, options = {}) {
81
+ const event = {
82
+ name,
83
+ ctrl: options.ctrl ?? false,
84
+ meta: options.meta ?? false,
85
+ shift: options.shift ?? false,
86
+ option: options.option ?? false,
87
+ sequence: options.sequence ?? name,
88
+ number: false,
89
+ raw: name,
90
+ eventType: "press",
91
+ source: "raw",
92
+ _defaultPrevented: false,
93
+ _propagationStopped: false,
94
+ get defaultPrevented() {
95
+ return this._defaultPrevented;
96
+ },
97
+ get propagationStopped() {
98
+ return this._propagationStopped;
99
+ },
100
+ preventDefault() {
101
+ this._defaultPrevented = true;
102
+ },
103
+ stopPropagation() {
104
+ this._propagationStopped = true;
105
+ }
106
+ };
107
+ this.ctx._internalKeyInput.emit("keypress", event);
108
+ this.ctx.keyInput.emit("keypress", event);
109
+ }
110
+ /** Type a string of text character by character */
111
+ type(text) {
112
+ for (const char of text) {
113
+ this.press(char);
114
+ }
115
+ }
116
+ /** Press a single character key */
117
+ press(char) {
118
+ this.sendKey(char);
119
+ }
120
+ /** Send raw data (for escape sequences etc.) */
121
+ raw(data) {
122
+ this.sendKey(data, { sequence: data });
123
+ }
124
+ // Common keys
125
+ enter() {
126
+ this.sendKey("return");
127
+ }
128
+ escape() {
129
+ this.sendKey("escape");
130
+ }
131
+ tab() {
132
+ this.sendKey("tab");
133
+ }
134
+ backspace() {
135
+ this.sendKey("backspace");
136
+ }
137
+ delete() {
138
+ this.sendKey("delete");
139
+ }
140
+ space() {
141
+ this.sendKey("space");
142
+ }
143
+ up() {
144
+ this.sendKey("up");
145
+ }
146
+ down() {
147
+ this.sendKey("down");
148
+ }
149
+ left() {
150
+ this.sendKey("left");
151
+ }
152
+ right() {
153
+ this.sendKey("right");
154
+ }
155
+ home() {
156
+ this.sendKey("home");
157
+ }
158
+ end() {
159
+ this.sendKey("end");
160
+ }
161
+ pageUp() {
162
+ this.sendKey("pageup");
163
+ }
164
+ pageDown() {
165
+ this.sendKey("pagedown");
166
+ }
167
+ };
168
+
169
+ // src/wait-for.ts
170
+ async function waitFor(screen, condition, options = {}) {
171
+ const { timeout = 3e3, interval = 50 } = options;
172
+ const start = Date.now();
173
+ while (true) {
174
+ try {
175
+ if (typeof condition === "string") {
176
+ if (screen.contains(condition)) return;
177
+ if (Date.now() - start > timeout) {
178
+ throw new Error(
179
+ `waitFor timed out after ${timeout}ms waiting for "${condition}"
180
+
181
+ Screen content:
182
+ ${screen.text()}`
183
+ );
184
+ }
185
+ } else {
186
+ condition();
187
+ return;
188
+ }
189
+ } catch (error) {
190
+ if (Date.now() - start > timeout) {
191
+ if (typeof condition === "string") {
192
+ throw new Error(
193
+ `waitFor timed out after ${timeout}ms waiting for "${condition}"
194
+
195
+ Screen content:
196
+ ${screen.text()}`
197
+ );
198
+ }
199
+ throw error;
200
+ }
201
+ }
202
+ await new Promise((resolve) => setTimeout(resolve, interval));
203
+ }
204
+ }
205
+
206
+ // src/render-tui.ts
207
+ var activeInstances = [];
208
+ var _webModule = await import("../../web/src/index");
209
+ var _rendererModule = await import("../../web/src/browser-renderer");
210
+ var _coreModule = await import("../../web/src/core-shims/index").catch(() => {
211
+ throw new Error(
212
+ "renderTui requires @opentui/core (RootRenderable). Make sure the opentui monorepo is available and module resolution is configured."
213
+ );
214
+ });
215
+ var _reconcilerModule = await import("../../../opentui/packages/react/src/reconciler/reconciler");
216
+ function renderTui(node, options = {}) {
217
+ const { cols = 80, rows = 24 } = options;
218
+ const { BrowserRenderer, createBrowserRoot } = _webModule;
219
+ const { setRootRenderableClass } = _rendererModule;
220
+ const { RootRenderable } = _coreModule;
221
+ setRootRenderableClass(RootRenderable);
222
+ const mockCanvas = createMockCanvas(cols, rows);
223
+ const renderer = new BrowserRenderer(mockCanvas, cols, rows);
224
+ const root = createBrowserRoot(renderer);
225
+ const screen = new Screen(renderer.buffer);
226
+ const keys = new KeySender(renderer.renderContext);
227
+ const _rec = _reconcilerModule.reconciler;
228
+ const _flushSync = _rec.flushSyncFromReconciler ?? _rec.flushSync;
229
+ _flushSync(() => {
230
+ root.render(node);
231
+ });
232
+ doRenderPass(renderer);
233
+ screen.captureFrame();
234
+ const instance = {
235
+ cleanup() {
236
+ renderer.stop();
237
+ root.unmount();
238
+ }
239
+ };
240
+ activeInstances.push(instance);
241
+ return {
242
+ screen,
243
+ keys,
244
+ waitFor: (condition, opts) => waitFor(screen, condition, opts),
245
+ flush() {
246
+ _flushSync(() => {
247
+ });
248
+ doRenderPass(renderer);
249
+ screen.captureFrame();
250
+ },
251
+ rerender(newNode) {
252
+ _flushSync(() => {
253
+ root.render(newNode);
254
+ });
255
+ doRenderPass(renderer);
256
+ screen.captureFrame();
257
+ },
258
+ unmount() {
259
+ instance.cleanup();
260
+ const idx = activeInstances.indexOf(instance);
261
+ if (idx >= 0) activeInstances.splice(idx, 1);
262
+ }
263
+ };
264
+ }
265
+ function cleanup() {
266
+ for (const instance of activeInstances) {
267
+ instance.cleanup();
268
+ }
269
+ activeInstances.length = 0;
270
+ }
271
+ function doRenderPass(renderer) {
272
+ const buffer = renderer.buffer;
273
+ const renderContext = renderer.renderContext;
274
+ buffer.clear();
275
+ const lifecyclePasses = renderContext.getLifecyclePasses();
276
+ for (const renderable of lifecyclePasses) {
277
+ if (renderable.onLifecyclePass) {
278
+ renderable.onLifecyclePass();
279
+ }
280
+ }
281
+ renderer.root.calculateLayout();
282
+ const renderList = [];
283
+ renderer.root.updateLayout(16, renderList);
284
+ for (const cmd of renderList) {
285
+ switch (cmd.action) {
286
+ case "pushScissorRect":
287
+ buffer.pushScissorRect(cmd.x, cmd.y, cmd.width, cmd.height);
288
+ break;
289
+ case "popScissorRect":
290
+ buffer.popScissorRect();
291
+ break;
292
+ case "pushOpacity":
293
+ buffer.pushOpacity(cmd.opacity);
294
+ break;
295
+ case "popOpacity":
296
+ buffer.popOpacity();
297
+ break;
298
+ case "render":
299
+ cmd.renderable.render(buffer, 16);
300
+ break;
301
+ }
302
+ }
303
+ buffer.clearScissorRects();
304
+ buffer.clearOpacity();
305
+ }
306
+ function createMockCanvas(cols, rows) {
307
+ const canvas = document.createElement("canvas");
308
+ const mockCtx = {
309
+ font: "",
310
+ fillStyle: "",
311
+ strokeStyle: "",
312
+ lineWidth: 1,
313
+ measureText: () => ({ width: 8 }),
314
+ fillRect: () => {
315
+ },
316
+ fillText: () => {
317
+ },
318
+ clearRect: () => {
319
+ },
320
+ beginPath: () => {
321
+ },
322
+ moveTo: () => {
323
+ },
324
+ lineTo: () => {
325
+ },
326
+ stroke: () => {
327
+ },
328
+ setTransform: () => {
329
+ },
330
+ scale: () => {
331
+ }
332
+ };
333
+ canvas.getContext = (() => mockCtx);
334
+ canvas.width = cols * 8;
335
+ canvas.height = rows * 16;
336
+ canvas.style.width = `${cols * 8}px`;
337
+ canvas.style.height = `${rows * 16}px`;
338
+ canvas.style.cursor = "";
339
+ canvas.tabIndex = 0;
340
+ canvas.getBoundingClientRect = () => ({
341
+ x: 0,
342
+ y: 0,
343
+ width: cols * 8,
344
+ height: rows * 16,
345
+ top: 0,
346
+ left: 0,
347
+ bottom: rows * 16,
348
+ right: cols * 8,
349
+ toJSON: () => {
350
+ }
351
+ });
352
+ return canvas;
353
+ }
354
+ export {
355
+ KeySender,
356
+ Screen,
357
+ cleanup,
358
+ renderTui,
359
+ waitFor
360
+ };
361
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/screen.ts","../src/keys.ts","../src/wait-for.ts","../src/render-tui.ts"],"sourcesContent":["/**\n * Minimal buffer interface — matches BrowserBuffer's public API.\n * This avoids a hard dependency on @gridland/web for the testing utilities.\n */\nexport interface ReadableBuffer {\n width: number\n height: number\n char: Uint32Array\n fg: Float32Array\n bg: Float32Array\n attributes: Uint32Array\n}\n\n/**\n * Screen provides query helpers for reading buffer content in tests.\n * Unlike ink-testing which parses ANSI, we read TypedArrays directly.\n */\nexport class Screen {\n private buffer: ReadableBuffer\n private _frames: string[] = []\n\n constructor(buffer: ReadableBuffer) {\n this.buffer = buffer\n }\n\n /** Capture a frame snapshot (call after each render) */\n captureFrame(): void {\n this._frames.push(this.text())\n }\n\n /** Get the current screen text (plain chars, trailing spaces trimmed) */\n text(): string {\n const lines: string[] = []\n for (let row = 0; row < this.buffer.height; row++) {\n let line = \"\"\n for (let col = 0; col < this.buffer.width; col++) {\n const idx = row * this.buffer.width + col\n const charCode = this.buffer.char[idx]\n line += charCode === 0 ? \" \" : String.fromCodePoint(charCode)\n }\n lines.push(line.trimEnd())\n }\n // Trim trailing empty lines\n while (lines.length > 0 && lines[lines.length - 1] === \"\") {\n lines.pop()\n }\n return lines.join(\"\\n\")\n }\n\n /** Get raw text including all spaces (no trimming) */\n rawText(): string {\n const lines: string[] = []\n for (let row = 0; row < this.buffer.height; row++) {\n let line = \"\"\n for (let col = 0; col < this.buffer.width; col++) {\n const idx = row * this.buffer.width + col\n const charCode = this.buffer.char[idx]\n line += charCode === 0 ? \" \" : String.fromCodePoint(charCode)\n }\n lines.push(line)\n }\n return lines.join(\"\\n\")\n }\n\n /** Check if the screen contains the given text */\n contains(text: string): boolean {\n return this.text().includes(text)\n }\n\n /** Check if the screen matches a regex */\n matches(pattern: RegExp): boolean {\n return pattern.test(this.text())\n }\n\n /** Get a specific line (0-indexed) */\n line(n: number): string {\n const lines = this.text().split(\"\\n\")\n return lines[n] ?? \"\"\n }\n\n /** Get all non-empty lines */\n lines(): string[] {\n return this.text().split(\"\\n\").filter((l) => l.length > 0)\n }\n\n /** Get all captured frames */\n frames(): string[] {\n return [...this._frames]\n }\n\n /** Get the number of columns */\n get width(): number {\n return this.buffer.width\n }\n\n /** Get the number of rows */\n get height(): number {\n return this.buffer.height\n }\n}\n","import { EventEmitter } from \"events\"\n\n/**\n * Minimal render context interface for key input.\n */\nexport interface KeyInputContext {\n keyInput: EventEmitter\n _internalKeyInput: EventEmitter\n}\n\n/**\n * KeySender simulates keyboard input for testing.\n */\nexport class KeySender {\n private ctx: KeyInputContext\n\n constructor(ctx: KeyInputContext) {\n this.ctx = ctx\n }\n\n private sendKey(name: string, options: {\n ctrl?: boolean\n meta?: boolean\n shift?: boolean\n option?: boolean\n sequence?: string\n } = {}): void {\n const event = {\n name,\n ctrl: options.ctrl ?? false,\n meta: options.meta ?? false,\n shift: options.shift ?? false,\n option: options.option ?? false,\n sequence: options.sequence ?? name,\n number: false,\n raw: name,\n eventType: \"press\" as const,\n source: \"raw\" as const,\n _defaultPrevented: false,\n _propagationStopped: false,\n get defaultPrevented() { return this._defaultPrevented },\n get propagationStopped() { return this._propagationStopped },\n preventDefault() { this._defaultPrevented = true },\n stopPropagation() { this._propagationStopped = true },\n }\n\n this.ctx._internalKeyInput.emit(\"keypress\", event)\n this.ctx.keyInput.emit(\"keypress\", event)\n }\n\n /** Type a string of text character by character */\n type(text: string): void {\n for (const char of text) {\n this.press(char)\n }\n }\n\n /** Press a single character key */\n press(char: string): void {\n this.sendKey(char)\n }\n\n /** Send raw data (for escape sequences etc.) */\n raw(data: string): void {\n this.sendKey(data, { sequence: data })\n }\n\n // Common keys\n enter(): void { this.sendKey(\"return\") }\n escape(): void { this.sendKey(\"escape\") }\n tab(): void { this.sendKey(\"tab\") }\n backspace(): void { this.sendKey(\"backspace\") }\n delete(): void { this.sendKey(\"delete\") }\n space(): void { this.sendKey(\"space\") }\n up(): void { this.sendKey(\"up\") }\n down(): void { this.sendKey(\"down\") }\n left(): void { this.sendKey(\"left\") }\n right(): void { this.sendKey(\"right\") }\n home(): void { this.sendKey(\"home\") }\n end(): void { this.sendKey(\"end\") }\n pageUp(): void { this.sendKey(\"pageup\") }\n pageDown(): void { this.sendKey(\"pagedown\") }\n}\n","import type { Screen } from \"./screen\"\n\nexport interface WaitForOptions {\n /** Timeout in ms (default: 3000) */\n timeout?: number\n /** Polling interval in ms (default: 50) */\n interval?: number\n}\n\n/**\n * Wait for a condition to be met on the screen.\n *\n * @param screen - The screen to poll\n * @param condition - Either a string (wait for screen to contain it) or a function (wait for it to not throw)\n * @param options - Timeout and interval settings\n */\nexport async function waitFor(\n screen: Screen,\n condition: string | (() => void),\n options: WaitForOptions = {},\n): Promise<void> {\n const { timeout = 3000, interval = 50 } = options\n const start = Date.now()\n\n while (true) {\n try {\n if (typeof condition === \"string\") {\n if (screen.contains(condition)) return\n if (Date.now() - start > timeout) {\n throw new Error(\n `waitFor timed out after ${timeout}ms waiting for \"${condition}\"\\n\\nScreen content:\\n${screen.text()}`,\n )\n }\n } else {\n condition()\n return\n }\n } catch (error) {\n if (Date.now() - start > timeout) {\n if (typeof condition === \"string\") {\n throw new Error(\n `waitFor timed out after ${timeout}ms waiting for \"${condition}\"\\n\\nScreen content:\\n${screen.text()}`,\n )\n }\n throw error\n }\n }\n\n await new Promise((resolve) => setTimeout(resolve, interval))\n }\n}\n","import type { ReactNode } from \"react\"\nimport { Screen, type ReadableBuffer } from \"./screen\"\nimport { KeySender, type KeyInputContext } from \"./keys\"\nimport { waitFor, type WaitForOptions } from \"./wait-for\"\n\nexport interface TuiInstance {\n /** Screen queries — read buffer content */\n screen: Screen\n /** Key input simulation */\n keys: KeySender\n /** Wait for text or assertion */\n waitFor: (condition: string | (() => void), options?: WaitForOptions) => Promise<void>\n /** Force a synchronous render cycle */\n flush: () => void\n /** Re-render with new content */\n rerender: (node: ReactNode) => void\n /** Unmount and clean up */\n unmount: () => void\n}\n\ninterface ActiveInstance {\n cleanup: () => void\n}\n\nconst activeInstances: ActiveInstance[] = []\n\nexport interface RenderTuiOptions {\n /** Number of columns (default: 80) */\n cols?: number\n /** Number of rows (default: 24) */\n rows?: number\n}\n\n// Pre-load async modules at module scope so renderTui() can stay synchronous.\n// The gridland-web module chain contains top-level await (reconciler devtools),\n// so require() fails — we use await import() here instead.\nconst _webModule = await import(\"../../web/src/index\")\nconst _rendererModule = await import(\"../../web/src/browser-renderer\")\nconst _coreModule = await import(\"../../web/src/core-shims/index\").catch(() => {\n throw new Error(\n \"renderTui requires @opentui/core (RootRenderable). \" +\n \"Make sure the opentui monorepo is available and module resolution is configured.\",\n )\n})\n// Import reconciler to flush concurrent work synchronously in tests\nconst _reconcilerModule = await import(\"../../../opentui/packages/react/src/reconciler/reconciler\")\n\n/**\n * Render a Gridland component for testing.\n *\n * Note: This function requires @opentui/core and the gridland-web browser runtime\n * to be available. It works in test environments that have the opentui monorepo\n * accessible and proper module resolution configured.\n *\n * For simpler testing (Screen, Keys, waitFor), use those classes directly\n * with a BrowserBuffer — they have no external dependencies.\n */\nexport function renderTui(node: ReactNode, options: RenderTuiOptions = {}): TuiInstance {\n const { cols = 80, rows = 24 } = options\n\n const { BrowserRenderer, createBrowserRoot } = _webModule as any\n const { setRootRenderableClass } = _rendererModule as any\n const { RootRenderable } = _coreModule as any\n setRootRenderableClass(RootRenderable)\n\n const mockCanvas = createMockCanvas(cols, rows)\n const renderer = new BrowserRenderer(mockCanvas, cols, rows)\n const root = createBrowserRoot(renderer)\n const screen = new Screen(renderer.buffer as ReadableBuffer)\n const keys = new KeySender(renderer.renderContext as KeyInputContext)\n\n // Flush the concurrent reconciler so the React tree is committed synchronously.\n // Wrapping root.render() inside flushSync ensures updateContainer runs in sync mode.\n const _rec = (_reconcilerModule as any).reconciler\n const _flushSync = _rec.flushSyncFromReconciler ?? _rec.flushSync\n _flushSync(() => {\n root.render(node)\n })\n doRenderPass(renderer)\n screen.captureFrame()\n\n const instance: ActiveInstance = {\n cleanup() {\n renderer.stop()\n root.unmount()\n },\n }\n activeInstances.push(instance)\n\n return {\n screen,\n keys,\n waitFor: (condition, opts) => waitFor(screen, condition, opts),\n flush() {\n _flushSync(() => {})\n doRenderPass(renderer)\n screen.captureFrame()\n },\n rerender(newNode: ReactNode) {\n _flushSync(() => {\n root.render(newNode)\n })\n doRenderPass(renderer)\n screen.captureFrame()\n },\n unmount() {\n instance.cleanup()\n const idx = activeInstances.indexOf(instance)\n if (idx >= 0) activeInstances.splice(idx, 1)\n },\n }\n}\n\n/**\n * Clean up all active test instances. Call in afterEach().\n */\nexport function cleanup(): void {\n for (const instance of activeInstances) {\n instance.cleanup()\n }\n activeInstances.length = 0\n}\n\nfunction doRenderPass(renderer: any): void {\n const buffer = renderer.buffer\n const renderContext = renderer.renderContext\n\n buffer.clear()\n\n const lifecyclePasses = renderContext.getLifecyclePasses()\n for (const renderable of lifecyclePasses) {\n if (renderable.onLifecyclePass) {\n renderable.onLifecyclePass()\n }\n }\n\n renderer.root.calculateLayout()\n\n const renderList: any[] = []\n renderer.root.updateLayout(16, renderList)\n\n for (const cmd of renderList) {\n switch (cmd.action) {\n case \"pushScissorRect\":\n buffer.pushScissorRect(cmd.x, cmd.y, cmd.width, cmd.height)\n break\n case \"popScissorRect\":\n buffer.popScissorRect()\n break\n case \"pushOpacity\":\n buffer.pushOpacity(cmd.opacity)\n break\n case \"popOpacity\":\n buffer.popOpacity()\n break\n case \"render\":\n cmd.renderable.render(buffer, 16)\n break\n }\n }\n\n buffer.clearScissorRects()\n buffer.clearOpacity()\n}\n\nfunction createMockCanvas(cols: number, rows: number): any {\n const canvas = document.createElement(\"canvas\")\n\n const mockCtx = {\n font: \"\",\n fillStyle: \"\",\n strokeStyle: \"\",\n lineWidth: 1,\n measureText: () => ({ width: 8 }),\n fillRect: () => {},\n fillText: () => {},\n clearRect: () => {},\n beginPath: () => {},\n moveTo: () => {},\n lineTo: () => {},\n stroke: () => {},\n setTransform: () => {},\n scale: () => {},\n }\n\n canvas.getContext = (() => mockCtx) as any\n canvas.width = cols * 8\n canvas.height = rows * 16\n canvas.style.width = `${cols * 8}px`\n canvas.style.height = `${rows * 16}px`\n canvas.style.cursor = \"\"\n canvas.tabIndex = 0\n\n canvas.getBoundingClientRect = () => ({\n x: 0, y: 0,\n width: cols * 8, height: rows * 16,\n top: 0, left: 0, bottom: rows * 16, right: cols * 8,\n toJSON: () => {},\n })\n\n return canvas\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@gridland/testing",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "import": "./dist/index.js",
8
+ "types": "./dist/index.d.ts"
9
+ }
10
+ },
11
+ "main": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "files": ["dist"],
14
+ "scripts": {
15
+ "build": "tsup",
16
+ "test": "bun test --preload ../web/test/preload.ts",
17
+ "test:ci": "bun test --preload ../web/test/preload.ts --randomize --rerun-each 3"
18
+ },
19
+ "dependencies": {
20
+ "events": "^3.3.0"
21
+ },
22
+ "devDependencies": {
23
+ "@happy-dom/global-registrator": "^17.4.4",
24
+ "@types/bun": "^1.3.10",
25
+ "@types/react": "^19.0.0",
26
+ "react": "^19.0.0",
27
+ "tsup": "^8.4.0",
28
+ "typescript": "^5.7.0"
29
+ },
30
+ "peerDependencies": {
31
+ "react": ">=19.0.0"
32
+ }
33
+ }