@tui-sandbox/library 0.0.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.
Files changed (57) hide show
  1. package/LICENSE +9 -0
  2. package/dist/src/client/validateMouseEvent.d.ts +1 -0
  3. package/dist/src/client/validateMouseEvent.js +13 -0
  4. package/dist/src/client/websocket-client.d.ts +25 -0
  5. package/dist/src/client/websocket-client.js +127 -0
  6. package/dist/src/server/connection/trpc.d.ts +44 -0
  7. package/dist/src/server/connection/trpc.js +5 -0
  8. package/dist/src/server/dirtree/index.d.ts +25 -0
  9. package/dist/src/server/dirtree/index.js +80 -0
  10. package/dist/src/server/dirtree/index.test.d.ts +1 -0
  11. package/dist/src/server/dirtree/index.test.js +172 -0
  12. package/dist/src/server/dirtree/json-to-zod.d.ts +1 -0
  13. package/dist/src/server/dirtree/json-to-zod.js +56 -0
  14. package/dist/src/server/index.d.ts +8 -0
  15. package/dist/src/server/index.js +49 -0
  16. package/dist/src/server/neovim/NeovimApplication.d.ts +25 -0
  17. package/dist/src/server/neovim/NeovimApplication.js +111 -0
  18. package/dist/src/server/neovim/environment/createTempDir.d.ts +3 -0
  19. package/dist/src/server/neovim/environment/createTempDir.js +41 -0
  20. package/dist/src/server/neovim/index.d.ts +13 -0
  21. package/dist/src/server/neovim/index.js +40 -0
  22. package/dist/src/server/server.d.ts +59 -0
  23. package/dist/src/server/server.js +55 -0
  24. package/dist/src/server/types.d.ts +13 -0
  25. package/dist/src/server/types.js +1 -0
  26. package/dist/src/server/updateTestdirectorySchemaFile.d.ts +6 -0
  27. package/dist/src/server/updateTestdirectorySchemaFile.js +13 -0
  28. package/dist/src/server/utilities/DisposableSingleApplication.d.ts +16 -0
  29. package/dist/src/server/utilities/DisposableSingleApplication.js +27 -0
  30. package/dist/src/server/utilities/Lazy.d.ts +6 -0
  31. package/dist/src/server/utilities/Lazy.js +13 -0
  32. package/dist/src/server/utilities/TerminalApplication.d.ts +21 -0
  33. package/dist/src/server/utilities/TerminalApplication.js +54 -0
  34. package/dist/src/server/utilities/tabId.d.ts +9 -0
  35. package/dist/src/server/utilities/tabId.js +2 -0
  36. package/dist/tsconfig.tsbuildinfo +1 -0
  37. package/package.json +41 -0
  38. package/src/client/style.css +26 -0
  39. package/src/client/validateMouseEvent.ts +15 -0
  40. package/src/client/websocket-client.ts +157 -0
  41. package/src/public/DejaVuSansMNerdFontMono-Regular.ttf +0 -0
  42. package/src/server/connection/trpc.ts +17 -0
  43. package/src/server/dirtree/index.test.ts +179 -0
  44. package/src/server/dirtree/index.ts +114 -0
  45. package/src/server/dirtree/json-to-zod.ts +58 -0
  46. package/src/server/index.ts +54 -0
  47. package/src/server/neovim/NeovimApplication.ts +134 -0
  48. package/src/server/neovim/environment/createTempDir.ts +46 -0
  49. package/src/server/neovim/index.ts +63 -0
  50. package/src/server/server.ts +67 -0
  51. package/src/server/types.ts +13 -0
  52. package/src/server/updateTestdirectorySchemaFile.ts +23 -0
  53. package/src/server/utilities/DisposableSingleApplication.ts +33 -0
  54. package/src/server/utilities/Lazy.ts +12 -0
  55. package/src/server/utilities/TerminalApplication.ts +88 -0
  56. package/src/server/utilities/tabId.ts +4 -0
  57. package/tsconfig.json +22 -0
@@ -0,0 +1,114 @@
1
+ import assert from "assert"
2
+ import type { Dree } from "dree"
3
+ import { scan, Type } from "dree"
4
+ import { format, resolveConfig } from "prettier"
5
+ import { fileURLToPath } from "url"
6
+ import { jsonToZod } from "./json-to-zod"
7
+
8
+ type TreeResult = { dree: Dree; allFiles: Dree[] }
9
+
10
+ /** Convert a directory tree to a TypeScript type. This is useful for testing
11
+ * as the initial state of the test directory is fully known in tests. */
12
+ export function getDirectoryTree(path: string): TreeResult {
13
+ const allFiles: Dree[] = []
14
+ const result = scan(
15
+ path,
16
+ {
17
+ exclude: [/.repro/, /testdirs/],
18
+ hash: false,
19
+ size: false,
20
+ sizeInBytes: false,
21
+ },
22
+ file => {
23
+ allFiles.push(file)
24
+ },
25
+ dir => {
26
+ allFiles.push(dir)
27
+ }
28
+ )
29
+
30
+ return { dree: result, allFiles }
31
+ }
32
+
33
+ type FileNode = {
34
+ type: Type.FILE
35
+ name: string
36
+ extension: string | undefined
37
+ stem: string
38
+ }
39
+ type DirectoryNode = {
40
+ type: Type.DIRECTORY
41
+ name: string
42
+ contents: Record<string, TreeNode>
43
+ }
44
+
45
+ type TreeNode = FileNode | DirectoryNode
46
+
47
+ export function convertDree(root: Dree): TreeNode {
48
+ if (root.type === Type.FILE) {
49
+ return {
50
+ name: root.name,
51
+ type: root.type,
52
+ extension: root.extension,
53
+ stem: root.extension ? root.name.slice(0, -root.extension.length) : root.name,
54
+ }
55
+ }
56
+
57
+ assert(root.children)
58
+ const node: DirectoryNode = {
59
+ name: root.name,
60
+ type: root.type,
61
+ contents: {},
62
+ }
63
+ for (const child of root.children) {
64
+ node.contents[child.name] = convertDree(child)
65
+ }
66
+
67
+ return node
68
+ }
69
+
70
+ export async function buildSchemaForDirectoryTree(result: TreeResult, name: string): Promise<string> {
71
+ const root = result.dree
72
+ assert(root.type === Type.DIRECTORY)
73
+ const node = convertDree(root)
74
+ const schema = (await jsonToZod(node, `${name}Schema`)).split("\n")
75
+
76
+ const lines = `
77
+ // Note: This file is autogenerated. Do not edit it directly.
78
+ //
79
+ // Describes the contents of the test directory, which is a blueprint for
80
+ // files and directories. Tests can create a unique, safe environment for
81
+ // interacting with the contents of such a directory.
82
+ //
83
+ // Having strong typing for the test directory contents ensures that tests can
84
+ // be written with confidence that the files and directories they expect are
85
+ // actually found. Otherwise the tests are brittle and can break easily.
86
+ `.split("\n")
87
+
88
+ const allFilePaths = result.allFiles.map(f => f.relativePath)
89
+ const ContentsSchema = `${name}ContentsSchema`
90
+ const ContentsSchemaType = `${name}ContentsSchemaType`
91
+ return [
92
+ ...lines,
93
+ ...schema,
94
+ `export const ${ContentsSchema} = ${name}Schema.shape.contents`,
95
+ `export type ${ContentsSchemaType} = z.infer<typeof ${name}Schema>`,
96
+ "",
97
+ `export type ${name} = ${ContentsSchemaType}["contents"]`,
98
+ "",
99
+ `export const testDirectoryFiles = z.enum(${JSON.stringify(allFilePaths, null, 2)})`,
100
+ `export type MyTestDirectoryFile = z.infer<typeof testDirectoryFiles>`,
101
+ ].join("\n")
102
+ }
103
+
104
+ const __filename = fileURLToPath(import.meta.url)
105
+
106
+ export async function buildTestDirectorySchema(testDirectoryPath: string): Promise<string> {
107
+ const dree = getDirectoryTree(testDirectoryPath)
108
+ let text = await buildSchemaForDirectoryTree(dree, "MyTestDirectory")
109
+
110
+ const options = await resolveConfig(__filename)
111
+ text = await format(text, { ...options, parser: "typescript" })
112
+
113
+ return text
114
+ }
@@ -0,0 +1,58 @@
1
+ import { format, resolveConfig } from "prettier"
2
+ import babelParser from "prettier/parser-babel"
3
+ import { fileURLToPath } from "url"
4
+
5
+ const __filename = fileURLToPath(import.meta.url)
6
+
7
+ export async function jsonToZod(object: unknown, name: string = "schema"): Promise<string> {
8
+ const parse = (o: unknown, seen: object[]): string => {
9
+ switch (typeof o) {
10
+ case "string":
11
+ return `z.literal("${o}")`
12
+ case "number":
13
+ return "z.number()"
14
+ case "bigint":
15
+ return "z.number().int()"
16
+ case "boolean":
17
+ return "z.boolean()"
18
+ case "object":
19
+ if (o === null) {
20
+ return "z.null()"
21
+ }
22
+ if (seen.find(_obj => Object.is(_obj, o))) {
23
+ throw new Error("Circular objects are not supported")
24
+ }
25
+ seen.push(o)
26
+ if (Array.isArray(o)) {
27
+ const options = o
28
+ .map(obj => parse(obj, seen))
29
+ .reduce((acc: string[], curr: string) => (acc.includes(curr) ? acc : [...acc, curr]), [])
30
+ if (options.length === 1) {
31
+ return `z.array(${options[0]})`
32
+ } else if (options.length > 1) {
33
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
34
+ return `z.array(z.union([${options}]))`
35
+ } else {
36
+ return `z.array(z.unknown())`
37
+ }
38
+ }
39
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
40
+ return `z.object({${Object.entries(o).map(([k, v]) => `'${k}':${parse(v, seen)}`)}})`
41
+ case "undefined":
42
+ return "z.undefined()"
43
+ case "function":
44
+ return "z.function()"
45
+ case "symbol":
46
+ default:
47
+ return "z.unknown()"
48
+ }
49
+ }
50
+
51
+ const prettierConfig = await resolveConfig(__filename)
52
+
53
+ return format(`import {z} from "zod"\n\nexport const ${name}=${parse(object, [])}`, {
54
+ ...(prettierConfig || {}),
55
+ parser: "babel",
56
+ plugins: [babelParser],
57
+ })
58
+ }
@@ -0,0 +1,54 @@
1
+ import type { AnyRouter } from "@trpc/server"
2
+ import { applyWSSHandler } from "@trpc/server/adapters/ws"
3
+ import "core-js/proposals/async-explicit-resource-management"
4
+ import { once } from "events"
5
+ import { WebSocketServer } from "ws"
6
+ import { createContext } from "./connection/trpc"
7
+ import type { TestServerConfig } from "./updateTestdirectorySchemaFile"
8
+ import { updateTestdirectorySchemaFile } from "./updateTestdirectorySchemaFile"
9
+
10
+ export class TestServer {
11
+ public constructor(private readonly port: number) {}
12
+
13
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
14
+ public async startAndRun<TRouter extends AnyRouter>(appRouter: TRouter, config: TestServerConfig): Promise<void> {
15
+ console.log("🚀 Server starting")
16
+
17
+ await updateTestdirectorySchemaFile(config)
18
+
19
+ const wss = new WebSocketServer({ port: this.port })
20
+ const handler = applyWSSHandler<TRouter>({
21
+ wss,
22
+ router: appRouter,
23
+ createContext,
24
+ // Enable heartbeat messages to keep connection open (disabled by default)
25
+ keepAlive: {
26
+ enabled: true,
27
+ // server ping message interval in milliseconds
28
+ pingMs: 30_000,
29
+ // connection is terminated if pong message is not received in this many milliseconds
30
+ pongWaitMs: 5000,
31
+ },
32
+ })
33
+
34
+ wss.on("connection", socket => {
35
+ console.log(`➕➕ Connection (${wss.clients.size})`)
36
+ socket.once("close", () => {
37
+ console.log(`➖➖ Connection (${wss.clients.size})`)
38
+ })
39
+ })
40
+ console.log(`✅ WebSocket Server listening on ws://localhost:${this.port}`)
41
+
42
+ await Promise.race([once(process, "SIGTERM"), once(process, "SIGINT")])
43
+ console.log("Shutting down...")
44
+ handler.broadcastReconnectNotification()
45
+ wss.close(error => {
46
+ if (error) {
47
+ console.error("Error closing WebSocket server", error)
48
+ process.exit(1)
49
+ }
50
+ console.log("WebSocket server closed")
51
+ process.exit(0)
52
+ })
53
+ }
54
+ }
@@ -0,0 +1,134 @@
1
+ import { exec } from "child_process"
2
+ import EventEmitter from "events"
3
+ import { existsSync } from "fs"
4
+ import path from "path"
5
+ import { fileURLToPath } from "url"
6
+ import type { TestDirectory } from "../types"
7
+ import { DisposableSingleApplication } from "../utilities/DisposableSingleApplication"
8
+ import { TerminalApplication } from "../utilities/TerminalApplication"
9
+
10
+ /*
11
+
12
+ Usage:
13
+ nvim [options] [file ...]
14
+
15
+ Options:
16
+ --cmd <cmd> Execute <cmd> before any config
17
+ +<cmd>, -c <cmd> Execute <cmd> after config and first file
18
+ -l <script> [args...] Execute Lua <script> (with optional args)
19
+ -S <session> Source <session> after loading the first file
20
+ -s <scriptin> Read Normal mode commands from <scriptin>
21
+ -u <config> Use this config file
22
+
23
+ -d Diff mode
24
+ -es, -Es Silent (batch) mode
25
+ -h, --help Print this help message
26
+ -i <shada> Use this shada file
27
+ -n No swap file, use memory only
28
+ -o[N] Open N windows (default: one per file)
29
+ -O[N] Open N vertical windows (default: one per file)
30
+ -p[N] Open N tab pages (default: one per file)
31
+ -R Read-only (view) mode
32
+ -v, --version Print version information
33
+ -V[N][file] Verbose [level][file]
34
+
35
+ -- Only file names after this
36
+ --api-info Write msgpack-encoded API metadata to stdout
37
+ --clean "Factory defaults" (skip user config and plugins, shada)
38
+ --embed Use stdin/stdout as a msgpack-rpc channel
39
+ --headless Don't start a user interface
40
+ --listen <address> Serve RPC API from this address
41
+ --remote[-subcommand] Execute commands remotely on a server
42
+ --server <address> Specify RPC server to send commands to
43
+ --startuptime <file> Write startup timing messages to <file>
44
+
45
+ See ":help startup-options" for all options.
46
+
47
+ $ nvim --version
48
+ NVIM v0.11.0-dev-608+g9d74dc3ac
49
+ Build type: Release
50
+ LuaJIT 2.1.1720049189
51
+ Run "nvim -V1 -v" for more info
52
+
53
+ */
54
+
55
+ const __dirname = fileURLToPath(new URL(".", import.meta.url))
56
+ export type StdoutMessage = "stdout"
57
+
58
+ export type StartNeovimGenericArguments = {
59
+ terminalDimensions?: { cols: number; rows: number }
60
+ filename?: string | { openInVerticalSplits: string[] }
61
+ startupScriptModifications?: string[]
62
+ }
63
+
64
+ export class NeovimApplication extends DisposableSingleApplication {
65
+ private testDirectory: TestDirectory | undefined
66
+ public readonly events: EventEmitter
67
+
68
+ public constructor(private readonly testEnvironmentPath: string) {
69
+ super()
70
+ this.events = new EventEmitter()
71
+ }
72
+
73
+ /**
74
+ * Kill the current application and start a new one with the given arguments.
75
+ */
76
+ public async startNextAndKillCurrent(
77
+ testDirectory: TestDirectory,
78
+ startArgs: StartNeovimGenericArguments
79
+ ): Promise<void> {
80
+ await this[Symbol.asyncDispose]()
81
+ this.testDirectory = testDirectory
82
+
83
+ const neovimArguments = ["-u", "test-setup.lua"]
84
+
85
+ if (startArgs.startupScriptModifications) {
86
+ for (const modification of startArgs.startupScriptModifications) {
87
+ const file = path.join(testDirectory.rootPathAbsolute, "config-modifications", modification)
88
+ if (!existsSync(file)) {
89
+ throw new Error(`startupScriptModifications file does not exist: ${file}`)
90
+ }
91
+
92
+ neovimArguments.push("-c", `lua dofile('${file}')`)
93
+ }
94
+ }
95
+
96
+ if (!startArgs.filename) {
97
+ startArgs.filename = "initial-file.txt"
98
+ }
99
+
100
+ if (typeof startArgs.filename === "string") {
101
+ const file = path.join(testDirectory.rootPathAbsolute, startArgs.filename)
102
+ neovimArguments.push(file)
103
+ } else if (startArgs.filename.openInVerticalSplits.length > 0) {
104
+ // `-O[N]` Open N vertical windows (default: one per file)
105
+ neovimArguments.push("-O")
106
+
107
+ for (const file of startArgs.filename.openInVerticalSplits) {
108
+ const filePath = path.join(testDirectory.rootPathAbsolute, file)
109
+ neovimArguments.push(filePath)
110
+ }
111
+ }
112
+ const stdout = this.events
113
+
114
+ this.application = TerminalApplication.start({
115
+ command: "nvim",
116
+ args: neovimArguments,
117
+
118
+ cwd: this.testEnvironmentPath,
119
+ env: process.env,
120
+ dimensions: startArgs.terminalDimensions,
121
+
122
+ onStdoutOrStderr(data: string) {
123
+ stdout.emit("stdout" satisfies StdoutMessage, data)
124
+ },
125
+ })
126
+ }
127
+
128
+ override async [Symbol.asyncDispose](): Promise<void> {
129
+ await super.killCurrent()
130
+ if (this.testDirectory) {
131
+ exec(`rm -rf ${this.testDirectory.rootPathAbsolute}`)
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,46 @@
1
+ import assert from "assert"
2
+ import { execSync } from "child_process"
3
+ import { Type } from "dree"
4
+ import { constants, readdirSync } from "fs"
5
+ import { access, mkdir, mkdtemp } from "fs/promises"
6
+ import path from "path"
7
+ import { convertDree, getDirectoryTree } from "../../dirtree"
8
+ import type { TestDirectory } from "../../types"
9
+ import type { TestServerConfig } from "../../updateTestdirectorySchemaFile"
10
+ import { updateTestdirectorySchemaFile } from "../../updateTestdirectorySchemaFile"
11
+
12
+ export async function createTempDir(config: TestServerConfig): Promise<TestDirectory> {
13
+ try {
14
+ const dir = await createUniqueDirectory(config.testEnvironmentPath)
15
+
16
+ readdirSync(config.testEnvironmentPath).forEach(entry => {
17
+ if (entry === "testdirs") return
18
+ if (entry === ".repro") return
19
+
20
+ execSync(`cp -a '${path.join(config.testEnvironmentPath, entry)}' ${dir}/`)
21
+ })
22
+ console.log(`Created test directory at ${dir}`)
23
+
24
+ const tree = convertDree(getDirectoryTree(dir).dree)
25
+ assert(tree.type === Type.DIRECTORY)
26
+
27
+ await updateTestdirectorySchemaFile(config)
28
+ return { rootPathAbsolute: dir, contents: tree.contents }
29
+ } catch (err) {
30
+ console.error(err)
31
+ throw err
32
+ }
33
+ }
34
+
35
+ async function createUniqueDirectory(testEnvironmentPath: string): Promise<string> {
36
+ const testdirs = path.join(testEnvironmentPath, "testdirs")
37
+ try {
38
+ await access(testdirs, constants.F_OK)
39
+ } catch {
40
+ await mkdir(testdirs)
41
+ }
42
+ const dir = await mkdtemp(path.join(testdirs, "dir-"))
43
+ assert(typeof dir === "string")
44
+
45
+ return dir
46
+ }
@@ -0,0 +1,63 @@
1
+ import type { Observable } from "@trpc/server/observable"
2
+ import { observable } from "@trpc/server/observable"
3
+ import assert from "assert"
4
+ import type { TestDirectory } from "../types"
5
+ import type { TestServerConfig } from "../updateTestdirectorySchemaFile"
6
+ import type { TabId } from "../utilities/tabId"
7
+ import { createTempDir } from "./environment/createTempDir"
8
+ import type { StartNeovimGenericArguments, StdoutMessage } from "./NeovimApplication"
9
+ import { NeovimApplication } from "./NeovimApplication"
10
+
11
+ const neovims = new Map<TabId["tabId"], NeovimApplication>()
12
+
13
+ export function onStdout(options: { client: TabId }, testEnvironmentPath: string): Observable<string, unknown> {
14
+ return observable<string>(emit => {
15
+ const tabId = options.client.tabId
16
+ const neovim = neovims.get(tabId) ?? new NeovimApplication(testEnvironmentPath)
17
+ if (neovims.get(tabId) === undefined) {
18
+ neovims.set(tabId, neovim)
19
+ }
20
+
21
+ const send = (data: unknown) => {
22
+ assert(typeof data === "string")
23
+ emit.next(data)
24
+ }
25
+
26
+ neovim.events.on("stdout" satisfies StdoutMessage, send)
27
+
28
+ return () => {
29
+ neovim.events.off("stdout" satisfies StdoutMessage, send)
30
+ void neovim[Symbol.asyncDispose]().finally(() => {
31
+ neovims.delete(tabId)
32
+ })
33
+ }
34
+ })
35
+ }
36
+
37
+ export async function start(
38
+ options: StartNeovimGenericArguments,
39
+ tabId: TabId,
40
+ config: TestServerConfig
41
+ ): Promise<TestDirectory> {
42
+ const neovim = neovims.get(tabId.tabId)
43
+ assert(neovim, `Neovim instance not found for client id ${tabId.tabId}`)
44
+
45
+ const testDirectory = await createTempDir(config)
46
+ await neovim.startNextAndKillCurrent(testDirectory, options)
47
+
48
+ const processId = neovim.processId()
49
+ assert(processId !== undefined, "Neovim was started without a process ID. This is a bug - please open an issue.")
50
+ console.log(`🚀 Started Neovim instance ${processId}`)
51
+
52
+ return testDirectory
53
+ }
54
+
55
+ export async function sendStdin(options: { tabId: TabId; data: string }): Promise<void> {
56
+ const neovim = neovims.get(options.tabId.tabId)
57
+ assert(
58
+ neovim !== undefined,
59
+ `Neovim instance for clientId not found - cannot send stdin. Maybe it's not started yet?`
60
+ )
61
+
62
+ await neovim.write(options.data)
63
+ }
@@ -0,0 +1,67 @@
1
+ import type { inferRouterInputs } from "@trpc/server"
2
+ import { z } from "zod"
3
+ import { TestServer } from "."
4
+ import { trpc } from "./connection/trpc"
5
+ import * as neovim from "./neovim"
6
+ import type { TestServerConfig } from "./updateTestdirectorySchemaFile"
7
+ import { tabIdSchema } from "./utilities/tabId"
8
+
9
+ /** Stack for managing resources that need to be disposed of when the server
10
+ * shuts down */
11
+ await using autocleanup = new AsyncDisposableStack()
12
+ autocleanup.defer(() => {
13
+ console.log("Closing any open test applications")
14
+ })
15
+ export { autocleanup }
16
+
17
+ function createAppRouter(config: TestServerConfig) {
18
+ const appRouter = trpc.router({
19
+ neovim: trpc.router({
20
+ start: trpc.procedure
21
+ .input(
22
+ z.object({
23
+ tabId: tabIdSchema,
24
+ terminalDimensions: z
25
+ .object({
26
+ cols: z.number(),
27
+ rows: z.number(),
28
+ })
29
+ .optional(),
30
+ startNeovimArguments: z.object({
31
+ filename: z
32
+ .union([
33
+ z.string(),
34
+ z.object({
35
+ openInVerticalSplits: z.array(z.string()),
36
+ }),
37
+ ])
38
+ .optional(),
39
+ startupScriptModifications: z.array(z.string()).optional(),
40
+ }),
41
+ })
42
+ )
43
+ .mutation(options => {
44
+ return neovim.start(options.input, options.input.tabId, config)
45
+ }),
46
+ onStdout: trpc.procedure.input(z.object({ client: tabIdSchema })).subscription(options => {
47
+ return neovim.onStdout(options.input, config.testEnvironmentPath)
48
+ }),
49
+ sendStdin: trpc.procedure.input(z.object({ tabId: tabIdSchema, data: z.string() })).mutation(options => {
50
+ return neovim.sendStdin(options.input)
51
+ }),
52
+ }),
53
+ })
54
+
55
+ return appRouter
56
+ }
57
+
58
+ export type AppRouter = ReturnType<typeof createAppRouter>
59
+ export type RouterInput = inferRouterInputs<AppRouter>
60
+
61
+ export async function startTestServer(config: TestServerConfig): Promise<TestServer> {
62
+ const testServer = new TestServer(3000)
63
+ const appRouter = createAppRouter(config)
64
+ await testServer.startAndRun(appRouter, config)
65
+
66
+ return testServer
67
+ }
@@ -0,0 +1,13 @@
1
+ /** Describes the contents of the test directory, which is a blueprint for
2
+ * files and directories. Tests can create a unique, safe environment for
3
+ * interacting with the contents of such a directory.
4
+ *
5
+ * Having strong typing for the test directory contents ensures that tests can
6
+ * be written with confidence that the files and directories they expect are
7
+ * actually found. Otherwise the tests are brittle and can break easily.
8
+ */
9
+ export type TestDirectory = {
10
+ /** The path to the unique test directory (the root). */
11
+ rootPathAbsolute: string
12
+ contents: object
13
+ }
@@ -0,0 +1,23 @@
1
+ import "core-js/proposals/async-explicit-resource-management"
2
+ import { readFileSync, writeFileSync } from "fs"
3
+ import { buildTestDirectorySchema } from "./dirtree"
4
+
5
+ export type TestServerConfig = {
6
+ testEnvironmentPath: string
7
+ outputFilePath: string
8
+ }
9
+
10
+ export async function updateTestdirectorySchemaFile({
11
+ testEnvironmentPath,
12
+ outputFilePath,
13
+ }: TestServerConfig): Promise<void> {
14
+ const newSchema = await buildTestDirectorySchema(testEnvironmentPath)
15
+ const oldSchema = readFileSync(outputFilePath, "utf-8")
16
+
17
+ if (oldSchema !== newSchema) {
18
+ // it's important to not write the file if the schema hasn't changed
19
+ // because file watchers will trigger on file changes and we don't want to
20
+ // trigger a build if the schema hasn't changed
21
+ writeFileSync(outputFilePath, newSchema)
22
+ }
23
+ }
@@ -0,0 +1,33 @@
1
+ import type { TerminalApplication } from "./TerminalApplication"
2
+
3
+ /** A testable application that can be started, killed, and given input. For a
4
+ * single instance of this interface, only a single instance can be running at
5
+ * a time (1 to 1 mapping).
6
+ *
7
+ * @typeParam T The type of context the tests should have, e.g. information
8
+ * about a custom directory that the application is running in.
9
+ *
10
+ */
11
+ export abstract class DisposableSingleApplication implements AsyncDisposable {
12
+ protected application: TerminalApplication | undefined
13
+
14
+ public async killCurrent(): Promise<void> {
15
+ await this.application?.killAndWait()
16
+ }
17
+
18
+ public async write(input: string): Promise<void> {
19
+ return this.application?.write(input)
20
+ }
21
+
22
+ public processId(): number | undefined {
23
+ return this.application?.processId
24
+ }
25
+
26
+ async [Symbol.asyncDispose](): Promise<void> {
27
+ if (this.processId() === undefined) {
28
+ return
29
+ }
30
+ console.log(`Killing current application ${this.processId()}...`)
31
+ await this.killCurrent()
32
+ }
33
+ }
@@ -0,0 +1,12 @@
1
+ export class Lazy<T> {
2
+ private value?: T
3
+
4
+ constructor(private readonly factory: () => T) {}
5
+
6
+ get(): T {
7
+ if (this.value === undefined) {
8
+ this.value = this.factory()
9
+ }
10
+ return this.value
11
+ }
12
+ }