diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..d0f2114 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,12 @@ +logLevel = "debug" # "debug" | "warn" | "error" +telemetry = false + +[install.lockfile] +save = false + +[test] +# always enable coverage +coverage = true + +# disable code coverage counting test files +coverageSkipTestFiles = true \ No newline at end of file diff --git a/src/bun/mod.ts b/src/bun/mod.ts index ab0d017..7ecd658 100644 --- a/src/bun/mod.ts +++ b/src/bun/mod.ts @@ -3,14 +3,12 @@ */ import { IRuntime, RunTimeType } from '../common/mod.ts'; -import BunFFI from './ffi.ts'; import BunTerminalIO from './terminal_io.ts'; import BunFileIO from './file_io.ts'; const BunRuntime: IRuntime = { name: RunTimeType.Bun, file: BunFileIO, - ffi: BunFFI, term: BunTerminalIO, onEvent: (eventName: string, handler) => process.on(eventName, handler), onExit: (cb: () => void): void => { diff --git a/src/bun/terminal_io.ts b/src/bun/terminal_io.ts index 5c962e0..9299b08 100644 --- a/src/bun/terminal_io.ts +++ b/src/bun/terminal_io.ts @@ -1,14 +1,13 @@ /** * Wrap the runtime-specific hook into stdin */ -import { ITerminal, ITerminalSize } from '../common/mod.ts'; +import { + defaultTerminalSize, + ITerminal, + ITerminalSize, +} from '../common/mod.ts'; import Ansi from '../common/ansi.ts'; -const defaultTerminalSize: ITerminalSize = { - rows: 24, - cols: 80, -}; - const BunTerminalIO: ITerminal = { // Deno only returns arguments passed to the script, so // remove the bun runtime executable, and entry script arguments diff --git a/src/bun/test_base.ts b/src/bun/test_base.ts index 233a73e..a962b22 100644 --- a/src/bun/test_base.ts +++ b/src/bun/test_base.ts @@ -1,42 +1,35 @@ /** * Adapt the bun test interface to the shared testing interface */ -import { expect, test as btest } from 'bun:test'; +import { describe, expect, test } from 'bun:test'; import { ITestBase } from '../common/mod.ts'; -class TestBase implements ITestBase { - test(name: string, fn: () => void) { - return btest(name, fn); - } - - assertEquals(actual: unknown, expected: unknown): void { - return expect(actual).toEqual(expected); - } - - assertExists(actual: unknown): void { - return expect(actual).toBeDefined(); - } - - assertInstanceOf(actual: unknown, expectedType: any): void { - return expect(actual).toBeInstanceOf(expectedType); - } - - assertNotEquals(actual: unknown, expected: unknown): void { - return expect(actual).not.toBe(expected); - } - - assertFalse(actual: boolean): void { - return expect(actual).toBe(false); - } - - assertTrue(actual: boolean): void { - return expect(actual).toBe(true); - } - - assertStrictEquals(actual: unknown, expected: unknown): void { - return expect(actual).toBe(expected); - } +export function testSuite(testObj: any) { + Object.keys(testObj).forEach((group) => { + describe(group, () => { + const groupObj = testObj[group]; + Object.keys(groupObj).forEach((testName) => { + test(testName, groupObj[testName]); + }); + }); + }); } -const testBase = new TestBase(); -export default testBase; +const BunTestBase: ITestBase = { + assertEquals: (actual: unknown, expected: unknown) => + expect(actual).toEqual(expected), + assertExists: (actual: unknown) => expect(actual).toBeDefined(), + assertFalse: (actual: boolean) => expect(actual).toBe(false), + assertInstanceOf: (actual: unknown, expectedType: any) => + expect(actual).toBeInstanceOf(expectedType), + assertNotEquals: (actual: unknown, expected: unknown) => + expect(actual).not.toBe(expected), + assertStrictEquals: (actual: unknown, expected: unknown) => + expect(actual).toBe(expected), + assertTrue: (actual: boolean) => expect(actual).toBe(true), + test, + testGroup: describe, + testSuite, +}; + +export default BunTestBase; diff --git a/src/common/all_test.ts b/src/common/all_test.ts new file mode 100644 index 0000000..5b61605 --- /dev/null +++ b/src/common/all_test.ts @@ -0,0 +1,177 @@ +import { getTestRunner } from './runtime.ts'; +import { Ansi, KeyCommand, readKey } from './ansi.ts'; +import { Document, Row } from './document.ts'; +import Buffer from './buffer.ts'; +import { + chars, + ctrl_key, + is_ascii, + is_control, + noop, + ord, + strlen, + truncate, +} from './utils.ts'; +import { Editor } from './editor.ts'; +import { defaultTerminalSize } from './termios.ts'; + +const t = await getTestRunner(); + +const testObj = { + 'ANSI::ANSI utils': { + 'Ansi.moveCursor': () => { + t.assertEquals(Ansi.moveCursor(1, 2), '\x1b[2;3H'); + }, + 'Ansi.moveCursorForward': () => { + t.assertEquals(Ansi.moveCursorForward(2), '\x1b[2C'); + }, + 'Ansi.moveCursorDown': () => { + t.assertEquals(Ansi.moveCursorDown(7), '\x1b[7B'); + }, + }, + 'ANSI::readKey()': { + 'readKey passthrough': () => { + // Ignore unhandled escape sequences + t.assertEquals(readKey('\x1b[]'), '\x1b[]'); + + // Pass explicitly mapped values right through + t.assertEquals(readKey(KeyCommand.ArrowUp), KeyCommand.ArrowUp); + t.assertEquals(readKey(KeyCommand.Home), KeyCommand.Home); + t.assertEquals(readKey(KeyCommand.Delete), KeyCommand.Delete); + }, + 'readKey Home': () => { + ['\x1b[1~', '\x1b[7~', '\x1b[H', '\x1bOH'].forEach((code) => { + t.assertEquals(readKey(code), KeyCommand.Home); + }); + }, + 'readKey End': () => { + ['\x1b[4~', '\x1b[8~', '\x1b[F', '\x1bOF'].forEach((code) => { + t.assertEquals(readKey(code), KeyCommand.End); + }); + }, + }, + Buffer: { + 'Buffer exists': () => { + const b = new Buffer(); + t.assertInstanceOf(b, Buffer); + t.assertEquals(b.strlen(), 0); + }, + 'Buffer.appendLine': () => { + const b = new Buffer(); + + // Carriage return and line feed + b.appendLine(); + t.assertEquals(b.strlen(), 2); + + b.clear(); + t.assertEquals(b.strlen(), 0); + + b.appendLine('foo'); + t.assertEquals(b.strlen(), 5); + }, + 'Buffer.append': () => { + const b = new Buffer(); + + b.append('foobar'); + t.assertEquals(b.strlen(), 6); + b.clear(); + + b.append('foobar', 3); + t.assertEquals(b.strlen(), 3); + }, + 'Buffer.flush': async () => { + const b = new Buffer(); + b.appendLine('foobarbaz' + Ansi.ClearLine); + t.assertEquals(b.strlen(), 14); + + await b.flush(); + + t.assertEquals(b.strlen(), 0); + }, + }, + Document: { + 'Document.empty': () => { + const doc = Document.empty(); + t.assertEquals(doc.numRows, 0); + t.assertTrue(doc.isEmpty()); + t.assertEquals(doc.row(0), null); + }, + 'Document.appendRow': () => { + const doc = Document.empty(); + doc.appendRow('foobar'); + t.assertEquals(doc.numRows, 1); + t.assertFalse(doc.isEmpty()); + t.assertInstanceOf(doc.row(0), Row); + }, + }, + 'Document::Row': { + 'new Row': () => { + const row = new Row(); + t.assertEquals(row.toString(), ''); + }, + }, + Editor: { + 'new Editor': () => { + const e = new Editor(defaultTerminalSize); + t.assertInstanceOf(e, Editor); + }, + }, + 'Util::Misc fns': { + 'noop fn': () => { + t.assertExists(noop); + t.assertEquals(noop(), undefined); + }, + }, + 'Util::String fns': { + 'ord() returns 256 on invalid string': () => { + t.assertEquals(ord(''), 256); + }, + 'ord() returns number on valid string': () => { + t.assertEquals(ord('a'), 97); + }, + 'chars() properly splits strings into unicode characters': () => { + t.assertEquals(chars('😺😸😹'), ['😺', '😸', '😹']); + }, + 'ctrl_key() returns expected values': () => { + const ctrl_a = ctrl_key('a'); + t.assertTrue(is_control(ctrl_a)); + t.assertEquals(ctrl_a, String.fromCodePoint(0x01)); + + const invalid = ctrl_key('😺'); + t.assertFalse(is_control(invalid)); + t.assertEquals(invalid, '😺'); + }, + 'is_ascii() properly discerns ascii chars': () => { + t.assertTrue(is_ascii('asjyverkjhsdf1928374')); + t.assertFalse(is_ascii('😺acalskjsdf')); + t.assertFalse(is_ascii('ab😺ac')); + }, + 'is_control() works as expected': () => { + t.assertFalse(is_control('abc')); + t.assertTrue(is_control(String.fromCodePoint(0x01))); + t.assertFalse(is_control('😺')); + }, + 'strlen() returns expected length for ascii strings': () => { + t.assertEquals(strlen('abc'), 'abc'.length); + }, + 'strlen() returns expected length for multibyte characters': () => { + t.assertEquals(strlen('😺😸😹'), 3); + t.assertNotEquals('😺😸😹'.length, strlen('😺😸😹')); + + // Skin tone modifier + base character + t.assertEquals(strlen('🀰🏼'), 2); + t.assertNotEquals('🀰🏼'.length, strlen('🀰🏼')); + + // This has 4 sub-characters, and 3 zero-width-joiners + t.assertEquals(strlen('πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦'), 7); + t.assertNotEquals('πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦'.length, strlen('πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦')); + }, + 'truncate() shortens strings': () => { + t.assertEquals(truncate('😺😸😹', 1), '😺'); + t.assertEquals(truncate('😺😸😹', 5), '😺😸😹'); + t.assertEquals(truncate('πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦', 5), 'πŸ‘¨β€πŸ‘©β€πŸ‘§'); + }, + }, +}; + +t.testSuite(testObj); diff --git a/src/common/ansi.ts b/src/common/ansi.ts index ab59288..d4d5873 100644 --- a/src/common/ansi.ts +++ b/src/common/ansi.ts @@ -4,10 +4,6 @@ export const ANSI_PREFIX = '\x1b['; -function esc(pieces: TemplateStringsArray): string { - return ANSI_PREFIX + pieces[0]; -} - /** * ANSI escapes for various inputs */ @@ -26,12 +22,12 @@ export enum KeyCommand { } export const Ansi = { - ClearLine: esc`K`, - ClearScreen: esc`2J`, - ResetCursor: esc`H`, - HideCursor: esc`?25l`, - ShowCursor: esc`?25h`, - GetCursorLocation: esc`6n`, + ClearLine: ANSI_PREFIX + 'K', + ClearScreen: ANSI_PREFIX + '2J', + ResetCursor: ANSI_PREFIX + 'H', + HideCursor: ANSI_PREFIX + '?25l', + ShowCursor: ANSI_PREFIX + '?25h', + GetCursorLocation: ANSI_PREFIX + '6n', moveCursor: function moveCursor(row: number, col: number): string { // Convert to 1-based counting row++; @@ -57,15 +53,15 @@ export function readKey(parsed: string): string { // Some keycodes have multiple potential inputs switch (parsed) { - case '\x1bOH': - case '\x1b[7~': case '\x1b[1~': + case '\x1b[7~': + case '\x1bOH': case '\x1b[H': return KeyCommand.Home; - case '\x1bOF': - case '\x1b[8~': case '\x1b[4~': + case '\x1b[8~': + case '\x1bOF': case '\x1b[F': return KeyCommand.End; diff --git a/src/common/ansi_test.ts b/src/common/ansi_test.ts deleted file mode 100644 index f466eb0..0000000 --- a/src/common/ansi_test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { getTestRunner } from './mod.ts'; -import Ansi, { KeyCommand, readKey } from './ansi.ts'; - -getTestRunner().then((t) => { - t.test('Ansi.moveCursor', () => { - t.assertEquals(Ansi.moveCursor(1, 2), '\x1b[2;3H'); - }); - - t.test('Ansi.moveCursorForward', () => { - t.assertEquals(Ansi.moveCursorForward(2), '\x1b[2C'); - }); - - t.test('Ansi.moveCursorDown', () => { - t.assertEquals(Ansi.moveCursorDown(7), '\x1b[7B'); - }); - - t.test('readKey', () => { - // Ignore unhandled escape sequences - t.assertEquals(readKey('\x1b[]'), '\x1b[]'); - - // Pass explicitly mapped values right through - t.assertEquals(readKey(KeyCommand.ArrowUp), KeyCommand.ArrowUp); - t.assertEquals(readKey(KeyCommand.Home), KeyCommand.Home); - - ['\x1bOH', '\x1b[7~', '\x1b[1~', '\x1b[H'].forEach((code) => { - t.assertEquals(readKey(code), KeyCommand.Home); - }); - - ['\x1bOF', '\x1b[8~', '\x1b[4~', '\x1b[F'].forEach((code) => { - t.assertEquals(readKey(code), KeyCommand.End); - }); - }); -}); diff --git a/src/common/buffer.ts b/src/common/buffer.ts index 0b9b8e2..5ead78b 100644 --- a/src/common/buffer.ts +++ b/src/common/buffer.ts @@ -1,5 +1,5 @@ import { strlen, truncate } from './utils.ts'; -import { getRuntime, importForRuntime } from './runtime.ts'; +import { getRuntime } from './runtime.ts'; class Buffer { #b = ''; @@ -23,7 +23,7 @@ class Buffer { * Output the contents of the buffer into stdout */ public async flush() { - const term = await importForRuntime('terminal_io'); + const { term } = await getRuntime(); await term.writeStdout(this.#b); this.clear(); } diff --git a/src/common/buffer_test.ts b/src/common/buffer_test.ts deleted file mode 100644 index 488e762..0000000 --- a/src/common/buffer_test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { getTestRunner } from './runtime.ts'; -import Buffer from './buffer.ts'; - -getTestRunner().then((t) => { - t.test('Buffer exists', () => { - const b = new Buffer(); - t.assertInstanceOf(b, Buffer); - t.assertEquals(b.strlen(), 0); - }); - - t.test('Buffer.appendLine', () => { - const b = new Buffer(); - - // Carriage return and line feed - b.appendLine(); - t.assertEquals(b.strlen(), 2); - - b.clear(); - t.assertEquals(b.strlen(), 0); - - b.appendLine('foo'); - t.assertEquals(b.strlen(), 5); - }); - - t.test('Buffer.append', () => { - const b = new Buffer(); - - b.append('foobar'); - t.assertEquals(b.strlen(), 6); - b.clear(); - - b.append('foobar', 3); - t.assertEquals(b.strlen(), 3); - }); - - t.test('Buffer.flush', async () => { - const b = new Buffer(); - b.append('foobarbaz'); - t.assertEquals(b.strlen(), 9); - - await b.flush(); - - t.assertEquals(b.strlen(), 0); - }); -}); diff --git a/src/common/document.ts b/src/common/document.ts index b92e0de..ec3f86a 100644 --- a/src/common/document.ts +++ b/src/common/document.ts @@ -55,7 +55,7 @@ export class Document { return null; } - protected appendRow(s: string): void { + public appendRow(s: string): void { this.#rows.push(new Row(s)); } } diff --git a/src/common/runtime.ts b/src/common/runtime.ts index 2e88040..0875e2a 100644 --- a/src/common/runtime.ts +++ b/src/common/runtime.ts @@ -95,11 +95,6 @@ export interface IRuntime { */ name: RunTimeType; - /** - * Runtime-specific FFI - */ - ffi: IFFI; - /** * Runtime-specific terminal functionality */ diff --git a/src/common/termios.ts b/src/common/termios.ts index 4b94765..873ed39 100644 --- a/src/common/termios.ts +++ b/src/common/termios.ts @@ -1,10 +1,15 @@ -import { die, getRuntime, IFFI } from './mod.ts'; +import { die, IFFI, importForRuntime, ITerminalSize } from './mod.ts'; export const STDIN_FILENO = 0; export const TCSANOW = 0; export const TERMIOS_SIZE = 60; +export const defaultTerminalSize: ITerminalSize = { + rows: 24, + cols: 80, +}; + /** * Implementation to toggle raw mode */ @@ -112,7 +117,7 @@ export const getTermios = async () => { } // Get the runtime-specific ffi wrappers - const { ffi } = await getRuntime(); + const ffi = await importForRuntime('ffi'); termiosSingleton = new Termios(ffi); return termiosSingleton; diff --git a/src/common/types.ts b/src/common/types.ts index d5c67d1..e14eac6 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -15,12 +15,14 @@ export interface IPoint { * The shared test interface, so tests can be run by both runtimes */ export interface ITestBase { - test(name: string, fn: () => void): void; assertEquals(actual: unknown, expected: unknown): void; + assertExists(actual: unknown): void; + assertFalse(actual: boolean): void; + assertInstanceOf(actual: unknown, expectedType: any): void; assertNotEquals(actual: unknown, expected: unknown): void; assertStrictEquals(actual: unknown, expected: unknown): void; - assertExists(actual: unknown): void; - assertInstanceOf(actual: unknown, expectedType: any): void; assertTrue(actual: boolean): void; - assertFalse(actual: boolean): void; + test(name: string, fn: () => void, timeout?: number): void; + testGroup(name: string, fn: () => void): void; + testSuite(testObj: any): void; } diff --git a/src/common/utils_test.ts b/src/common/utils_test.ts deleted file mode 100644 index a4900d8..0000000 --- a/src/common/utils_test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { getTestRunner } from './mod.ts'; -import { - chars, - ctrl_key, - is_ascii, - is_control, - noop, - ord, - strlen, - truncate, -} from './utils.ts'; - -getTestRunner().then((t) => { - t.test('noop fn', () => { - t.assertExists(noop); - t.assertEquals(noop(), undefined); - }); - - // --------------------------------------------------------------------------- - // Strings - // --------------------------------------------------------------------------- - t.test('ord fn returns 256 on invalid string', () => { - t.assertEquals(ord(''), 256); - }); - - t.test('chars fn properly splits strings into unicode characters', () => { - t.assertEquals(chars('😺😸😹'), ['😺', '😸', '😹']); - }); - - t.test('ctrl_key fn returns expected values', () => { - const ctrl_a = ctrl_key('a'); - t.assertTrue(is_control(ctrl_a)); - t.assertEquals(ctrl_a, String.fromCodePoint(0x01)); - - const invalid = ctrl_key('😺'); - t.assertFalse(is_control(invalid)); - t.assertEquals(invalid, '😺'); - }); - - t.test('is_ascii properly discerns ascii chars', () => { - t.assertTrue(is_ascii('asjyverkjhsdf1928374')); - t.assertFalse(is_ascii('😺acalskjsdf')); - t.assertFalse(is_ascii('ab😺ac')); - }); - - t.test('is_control fn works as expected', () => { - t.assertFalse(is_control('abc')); - t.assertTrue(is_control(String.fromCodePoint(0x01))); - t.assertFalse(is_control('😺')); - }); - - t.test('strlen fn returns expected length for ascii strings', () => { - t.assertEquals(strlen('abc'), 'abc'.length); - }); - - t.test('strlen fn returns expected length for multibyte characters', () => { - t.assertEquals(strlen('😺😸😹'), 3); - t.assertNotEquals('😺😸😹'.length, strlen('😺😸😹')); - - // Skin tone modifier + base character - t.assertEquals(strlen('🀰🏼'), 2); - t.assertNotEquals('🀰🏼'.length, strlen('🀰🏼')); - - // This has 4 sub-characters, and 3 zero-width-joiners - t.assertEquals(strlen('πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦'), 7); - t.assertNotEquals('πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦'.length, strlen('πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦')); - }); - - t.test('truncate shortens strings', () => { - t.assertEquals(truncate('😺😸😹', 1), '😺'); - t.assertEquals(truncate('😺😸😹', 5), '😺😸😹'); - t.assertEquals(truncate('πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦', 5), 'πŸ‘¨β€πŸ‘©β€πŸ‘§'); - }); -}); diff --git a/src/deno/mod.ts b/src/deno/mod.ts index f7835b0..48d33f4 100644 --- a/src/deno/mod.ts +++ b/src/deno/mod.ts @@ -2,14 +2,12 @@ * The main entrypoint when using Deno as the runtime */ import { IRuntime, RunTimeType } from '../common/mod.ts'; -import DenoFFI from './ffi.ts'; import DenoTerminalIO from './terminal_io.ts'; import DenoFileIO from './file_io.ts'; const DenoRuntime: IRuntime = { name: RunTimeType.Deno, file: DenoFileIO, - ffi: DenoFFI, term: DenoTerminalIO, onEvent: (eventName: string, handler) => globalThis.addEventListener(eventName, handler), diff --git a/src/deno/test_base.ts b/src/deno/test_base.ts index d6070fb..b1a1ec5 100644 --- a/src/deno/test_base.ts +++ b/src/deno/test_base.ts @@ -9,43 +9,34 @@ const { assertStrictEquals, } = stdAssert; -class TestBase implements ITestBase { - test(name: string, fn: () => void): void { - return Deno.test(name, fn); - } +export function testSuite(testObj: any) { + Object.keys(testObj).forEach((group) => { + const groupObj = testObj[group]; + Object.keys(groupObj).forEach((testName) => { + Deno.test(testName, groupObj[testName]); + }); + }); +} - assertEquals(actual: unknown, expected: unknown): void { - return assertEquals(actual, expected); - } - - assertStrictEquals(actual: unknown, expected: unknown) { - return assertStrictEquals(actual, expected); - } - - assertNotEquals(actual: unknown, expected: unknown): void { - return assertNotEquals(actual, expected); - } - - assertExists(actual: unknown, msg?: string): void { - return assertExists(actual, msg); - } - - assertInstanceOf(actual: unknown, expectedType: any): void { - return assertInstanceOf(actual, expectedType); - } - - assertTrue(actual: boolean): void { +const DenoTestBase: ITestBase = { + assertEquals, + assertExists, + assertInstanceOf, + assertNotEquals, + assertStrictEquals, + assertTrue: function (actual: boolean): void { if (actual !== true) { throw new AssertionError(`actual: "${actual}" expected to be true"`); } - } - + }, assertFalse(actual: boolean): void { if (actual !== false) { throw new AssertionError(`actual: "${actual}" expected to be false"`); } - } -} + }, + test: Deno.test, + testGroup: (_name: string, fn) => fn(), + testSuite, +}; -const testBase = new TestBase(); -export default testBase; +export default DenoTestBase;