@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.
- package/LICENSE +9 -0
- package/dist/src/client/validateMouseEvent.d.ts +1 -0
- package/dist/src/client/validateMouseEvent.js +13 -0
- package/dist/src/client/websocket-client.d.ts +25 -0
- package/dist/src/client/websocket-client.js +127 -0
- package/dist/src/server/connection/trpc.d.ts +44 -0
- package/dist/src/server/connection/trpc.js +5 -0
- package/dist/src/server/dirtree/index.d.ts +25 -0
- package/dist/src/server/dirtree/index.js +80 -0
- package/dist/src/server/dirtree/index.test.d.ts +1 -0
- package/dist/src/server/dirtree/index.test.js +172 -0
- package/dist/src/server/dirtree/json-to-zod.d.ts +1 -0
- package/dist/src/server/dirtree/json-to-zod.js +56 -0
- package/dist/src/server/index.d.ts +8 -0
- package/dist/src/server/index.js +49 -0
- package/dist/src/server/neovim/NeovimApplication.d.ts +25 -0
- package/dist/src/server/neovim/NeovimApplication.js +111 -0
- package/dist/src/server/neovim/environment/createTempDir.d.ts +3 -0
- package/dist/src/server/neovim/environment/createTempDir.js +41 -0
- package/dist/src/server/neovim/index.d.ts +13 -0
- package/dist/src/server/neovim/index.js +40 -0
- package/dist/src/server/server.d.ts +59 -0
- package/dist/src/server/server.js +55 -0
- package/dist/src/server/types.d.ts +13 -0
- package/dist/src/server/types.js +1 -0
- package/dist/src/server/updateTestdirectorySchemaFile.d.ts +6 -0
- package/dist/src/server/updateTestdirectorySchemaFile.js +13 -0
- package/dist/src/server/utilities/DisposableSingleApplication.d.ts +16 -0
- package/dist/src/server/utilities/DisposableSingleApplication.js +27 -0
- package/dist/src/server/utilities/Lazy.d.ts +6 -0
- package/dist/src/server/utilities/Lazy.js +13 -0
- package/dist/src/server/utilities/TerminalApplication.d.ts +21 -0
- package/dist/src/server/utilities/TerminalApplication.js +54 -0
- package/dist/src/server/utilities/tabId.d.ts +9 -0
- package/dist/src/server/utilities/tabId.js +2 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +41 -0
- package/src/client/style.css +26 -0
- package/src/client/validateMouseEvent.ts +15 -0
- package/src/client/websocket-client.ts +157 -0
- package/src/public/DejaVuSansMNerdFontMono-Regular.ttf +0 -0
- package/src/server/connection/trpc.ts +17 -0
- package/src/server/dirtree/index.test.ts +179 -0
- package/src/server/dirtree/index.ts +114 -0
- package/src/server/dirtree/json-to-zod.ts +58 -0
- package/src/server/index.ts +54 -0
- package/src/server/neovim/NeovimApplication.ts +134 -0
- package/src/server/neovim/environment/createTempDir.ts +46 -0
- package/src/server/neovim/index.ts +63 -0
- package/src/server/server.ts +67 -0
- package/src/server/types.ts +13 -0
- package/src/server/updateTestdirectorySchemaFile.ts +23 -0
- package/src/server/utilities/DisposableSingleApplication.ts +33 -0
- package/src/server/utilities/Lazy.ts +12 -0
- package/src/server/utilities/TerminalApplication.ts +88 -0
- package/src/server/utilities/tabId.ts +4 -0
- 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
|
+
}
|