@tui-sandbox/library 7.2.0 → 7.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tui-sandbox/library",
3
- "version": "7.2.0",
3
+ "version": "7.3.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,8 +8,8 @@
8
8
  },
9
9
  "dependencies": {
10
10
  "@catppuccin/palette": "1.7.1",
11
- "@trpc/client": "11.0.0-rc.643",
12
- "@trpc/server": "11.0.0-rc.643",
11
+ "@trpc/client": "11.0.0-rc.648",
12
+ "@trpc/server": "11.0.0-rc.648",
13
13
  "@xterm/addon-attach": "0.11.0",
14
14
  "@xterm/addon-fit": "0.10.0",
15
15
  "@xterm/xterm": "5.5.0",
@@ -20,9 +20,9 @@
20
20
  "express": "4.21.1",
21
21
  "neovim": "5.3.0",
22
22
  "node-pty": "1.0.0",
23
- "prettier": "3.3.3",
23
+ "prettier": "3.4.1",
24
24
  "tsx": "4.19.2",
25
- "type-fest": "4.27.0",
25
+ "type-fest": "4.29.0",
26
26
  "winston": "3.17.0",
27
27
  "zod": "3.23.8"
28
28
  },
@@ -31,10 +31,10 @@
31
31
  "@types/command-exists": "1.2.3",
32
32
  "@types/cors": "2.8.17",
33
33
  "@types/express": "5.0.0",
34
- "@types/node": "22.9.1",
34
+ "@types/node": "22.10.1",
35
35
  "nodemon": "3.1.7",
36
- "vite": "5.4.11",
37
- "vitest": "2.1.5"
36
+ "vite": "6.0.1",
37
+ "vitest": "2.1.6"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "cypress": "^13",
@@ -1,32 +1,63 @@
1
+ import assert from "node:assert"
1
2
  import { stat } from "node:fs/promises"
2
3
  import path from "node:path"
3
4
  import { createCypressSupportFile } from "../server/cypress-support/createCypressSupportFile.js"
4
5
  import type { TestServerConfig } from "../server/index.js"
5
6
  import { startTestServer, updateTestdirectorySchemaFile } from "../server/index.js"
7
+ import type { StdoutOrStderrMessage } from "../server/neovim/NeovimApplication.js"
8
+ import { NeovimApplication } from "../server/neovim/NeovimApplication.js"
9
+ import { prepareNewTestDirectory } from "../server/neovim/index.js"
6
10
 
7
11
  //
8
12
  // This is the main entrypoint to tui-sandbox
9
13
  //
10
14
 
11
- // the arguments passed to this script start at index 2
12
- const args = process.argv.slice(2)
13
-
14
- if (args[0] !== "start") {
15
- throw new Error(`Usage: tui start`)
16
- }
17
15
  const outputFileName = "MyTestDirectory.ts"
18
16
 
19
17
  /** The cwd in the user's directory when they are running this script. Not the
20
18
  * cwd of the script itself. */
21
19
  const cwd = process.cwd()
20
+ const config = {
21
+ testEnvironmentPath: path.join(cwd, "test-environment/"),
22
+ outputFilePath: path.join(cwd, outputFileName),
23
+ } satisfies TestServerConfig
24
+
25
+ // the arguments passed to this script start at index 2
26
+ const args = process.argv.slice(2)
27
+
28
+ if (args[0] === "neovim") {
29
+ if (!(args[1] === "exec" && args.length === 3)) {
30
+ showUsageAndExit()
31
+ }
32
+
33
+ const command = args[2]
34
+ assert(command, "No command provided")
35
+
36
+ {
37
+ // automatically dispose of the neovim instance when done
38
+ await using app = new NeovimApplication(config.testEnvironmentPath)
39
+ app.events.on("stdout" satisfies StdoutOrStderrMessage, data => {
40
+ console.log(` neovim output: ${data}`)
41
+ })
42
+ const testDirectory = await prepareNewTestDirectory(config)
43
+ await app.startNextAndKillCurrent(
44
+ testDirectory,
45
+ { filename: "empty.txt", headlessCmd: command },
46
+ { cols: 80, rows: 24 }
47
+ )
48
+ await app.application.untilExit()
49
+ }
50
+
51
+ process.exit(0)
52
+ }
53
+
54
+ if (args[0] !== "start") {
55
+ showUsageAndExit()
56
+ }
22
57
  console.log(`🚀 Starting test server in ${cwd} - this should be the root of your integration-tests directory 🤞🏻`)
23
58
  await stat(path.join(cwd, outputFileName))
24
59
 
25
60
  try {
26
- const config = {
27
- testEnvironmentPath: path.join(cwd, "test-environment/"),
28
- outputFilePath: path.join(cwd, outputFileName),
29
- } satisfies TestServerConfig
30
61
  await createCypressSupportFile({
31
62
  cypressSupportDirectoryPath: path.join(cwd, "cypress", "support"),
32
63
  supportFileName: "tui-sandbox.ts",
@@ -36,3 +67,16 @@ try {
36
67
  } catch (e) {
37
68
  console.error(e)
38
69
  }
70
+
71
+ function showUsageAndExit() {
72
+ console.log(
73
+ [
74
+ //
75
+ `Usage (pick one):`,
76
+ ` tui start`,
77
+ ` tui neovim exec '<ex-command>'`,
78
+ ].join("\n")
79
+ )
80
+
81
+ process.exit(1)
82
+ }
@@ -39,7 +39,9 @@ export class TestServer {
39
39
  } catch (e) {
40
40
  // This is normal when developing the tui-sandbox library locally. It
41
41
  // should always exist when using it as an npm package, however.
42
- console.log(`⚠️ Warning: the tui-sandbox root contents directory is not accessible at: ${publicPath}`)
42
+ console.log(
43
+ `⚠️ Warning: Looks like the tui-sandbox root contents directory is not accessible at: ${publicPath}`
44
+ )
43
45
  }
44
46
 
45
47
  // eslint-disable-next-line import-x/no-named-as-default-member
@@ -47,24 +49,24 @@ export class TestServer {
47
49
  }
48
50
 
49
51
  app.use("/ping", (_, res) => {
50
- console.log("🏓 received /ping")
52
+ // console.log("🏓 received /ping")
51
53
  res.send("pong")
52
54
  })
53
55
 
54
56
  const server = app.listen(this.settings.port, "0.0.0.0")
55
57
 
56
58
  server.on("connection", socket => {
57
- const connectionInfo = `${socket.remoteAddress}:${socket.remotePort}`
58
- console.log(`➕➕ Connection from ${connectionInfo}`)
59
+ // const connectionInfo = `${socket.remoteAddress}:${socket.remotePort}`
60
+ // console.log(`➕➕ Connection from ${connectionInfo}`)
59
61
  socket.once("close", () => {
60
- console.log(`➖➖ Connection from ${connectionInfo}`)
62
+ // console.log(`➖➖ Connection from ${connectionInfo}`)
61
63
  })
62
64
  })
63
65
 
64
66
  console.log(`✅ Server listening on port ${this.settings.port}`)
65
67
 
66
68
  await Promise.race([once(process, "SIGTERM"), once(process, "SIGINT")])
67
- console.log("Shutting down...")
69
+ console.log("😴 Shutting down...")
68
70
  server.close(error => {
69
71
  if (error) {
70
72
  console.error("Error closing server", error)
@@ -56,12 +56,15 @@ Run "nvim -V1 -v" for more info
56
56
 
57
57
  */
58
58
 
59
- export type StdoutMessage = "stdout"
59
+ export type StdoutOrStderrMessage = "stdout"
60
60
 
61
61
  export type StartNeovimGenericArguments = {
62
62
  filename: string | { openInVerticalSplits: string[] }
63
63
  startupScriptModifications?: string[]
64
64
 
65
+ /** Executes the given command with --headless -c <command> -c qa */
66
+ headlessCmd?: string
67
+
65
68
  /** Additions to the environment variables for the Neovim process. These
66
69
  * override any already existing environment variables. */
67
70
  additionalEnvironmentVariables?: Record<string, string> | undefined
@@ -128,6 +131,13 @@ export class NeovimApplication {
128
131
  }
129
132
  }
130
133
 
134
+ if (startArgs.headlessCmd) {
135
+ // NOTE: update the doc comment above if this changes
136
+ neovimArguments.push("--headless")
137
+ neovimArguments.push("-c", startArgs.headlessCmd)
138
+ neovimArguments.push("-c", "qa")
139
+ }
140
+
131
141
  const id = Math.random().toString().slice(2, 8)
132
142
  const socketPath = `${tmpdir()}/tui-sandbox-nvim-socket-${id}`
133
143
  neovimArguments.push("--listen", socketPath)
@@ -146,7 +156,7 @@ export class NeovimApplication {
146
156
 
147
157
  onStdoutOrStderr(data) {
148
158
  data satisfies string
149
- stdout.emit("stdout" satisfies StdoutMessage, data)
159
+ stdout.emit("stdout" satisfies StdoutOrStderrMessage, data)
150
160
  },
151
161
  })
152
162
  })
@@ -14,10 +14,10 @@ export function connectNeovimApi(socketPath: string): Lazy<Promise<NeovimJavascr
14
14
  for (let i = 0; i < 100; i++) {
15
15
  try {
16
16
  await access(socketPath)
17
- console.log(`socket file ${socketPath} created after at attempt ${i + 1}`)
17
+ // console.log(`socket file ${socketPath} created after at attempt ${i + 1}`)
18
18
  break
19
19
  } catch (e) {
20
- console.log(`polling for socket file ${socketPath} to be created (attempt ${i + 1})`)
20
+ // console.log(`polling for socket file ${socketPath} to be created (attempt ${i + 1})`)
21
21
  await new Promise(resolve => setTimeout(resolve, 100 satisfies PollingInterval))
22
22
  }
23
23
  }
@@ -12,14 +12,18 @@ import type {
12
12
  } from "../types.js"
13
13
  import type { TestServerConfig } from "../updateTestdirectorySchemaFile.js"
14
14
  import { convertEventEmitterToAsyncGenerator } from "../utilities/generator.js"
15
+ import { Lazy } from "../utilities/Lazy.js"
15
16
  import type { TabId } from "../utilities/tabId.js"
16
17
  import { createTempDir, removeTestDirectories } from "./environment/createTempDir.js"
17
18
  import type { TerminalDimensions } from "./NeovimApplication.js"
18
19
  import { NeovimApplication } from "./NeovimApplication.js"
19
20
 
20
21
  const neovims = new Map<TabId["tabId"], NeovimApplication>()
22
+ export const resources: Lazy<AsyncDisposableStack> = new Lazy(() => {
23
+ return new AsyncDisposableStack()
24
+ })
21
25
 
22
- export async function onStdout(
26
+ export async function initializeStdout(
23
27
  options: { client: TabId },
24
28
  signal: AbortSignal | undefined,
25
29
  testEnvironmentPath: string
@@ -28,6 +32,9 @@ export async function onStdout(
28
32
  const neovim = neovims.get(tabId) ?? new NeovimApplication(testEnvironmentPath)
29
33
  if (neovims.get(tabId) === undefined) {
30
34
  neovims.set(tabId, neovim)
35
+ resources.get().adopt(neovim, async n => {
36
+ await n[Symbol.asyncDispose]()
37
+ })
31
38
  }
32
39
 
33
40
  const stdout = convertEventEmitterToAsyncGenerator(neovim.events, "stdout")
@@ -51,13 +58,18 @@ export async function start(
51
58
  const neovim = neovims.get(tabId.tabId)
52
59
  assert(neovim, `Neovim instance not found for client id ${tabId.tabId}`)
53
60
 
54
- await removeTestDirectories(config.testEnvironmentPath)
55
- const testDirectory = await createTempDir(config)
61
+ const testDirectory = await prepareNewTestDirectory(config)
56
62
  await neovim.startNextAndKillCurrent(testDirectory, options, terminalDimensions)
57
63
 
58
64
  return testDirectory
59
65
  }
60
66
 
67
+ export async function prepareNewTestDirectory(config: TestServerConfig): Promise<TestDirectory> {
68
+ await removeTestDirectories(config.testEnvironmentPath)
69
+ const testDirectory = await createTempDir(config)
70
+ return testDirectory
71
+ }
72
+
61
73
  export async function sendStdin(options: { tabId: TabId; data: string }): Promise<void> {
62
74
  const neovim = neovims.get(options.tabId.tabId)
63
75
  assert(
@@ -71,7 +71,7 @@ export async function createAppRouter(config: TestServerConfig) {
71
71
  )
72
72
  }),
73
73
  onStdout: trpc.procedure.input(z.object({ client: tabIdSchema })).subscription(options => {
74
- return neovim.onStdout(options.input, options.signal, config.testEnvironmentPath)
74
+ return neovim.initializeStdout(options.input, options.signal, config.testEnvironmentPath)
75
75
  }),
76
76
  sendStdin: trpc.procedure.input(z.object({ tabId: tabIdSchema, data: z.string() })).mutation(options => {
77
77
  return neovim.sendStdin(options.input)
@@ -1,5 +1,6 @@
1
1
  import type { StartableApplication } from "./DisposableSingleApplication.js"
2
2
  import { DisposableSingleApplication } from "./DisposableSingleApplication.js"
3
+ import type { ExitInfo } from "./TerminalApplication.js"
3
4
 
4
5
  vi.spyOn(console, "log").mockImplementation(vi.fn())
5
6
 
@@ -9,11 +10,12 @@ class TestDisposableSingleApplication extends DisposableSingleApplication {
9
10
  }
10
11
  }
11
12
 
12
- const fakeApp: StartableApplication = {
13
+ const fakeApp = {
13
14
  processId: 123,
14
15
  write: vi.fn(),
15
16
  killAndWait: vi.fn(),
16
- }
17
+ untilExit: Promise.resolve<ExitInfo>({ exitCode: 0, signal: undefined }),
18
+ } satisfies StartableApplication
17
19
 
18
20
  describe("DisposableSingleApplication", () => {
19
21
  it("has no application when created", () => {
@@ -45,6 +47,25 @@ describe("DisposableSingleApplication", () => {
45
47
  )
46
48
  })
47
49
 
50
+ describe("untilExit allows waiting for the application to exit", () => {
51
+ it("successful exit works", async () => {
52
+ const app = new TestDisposableSingleApplication()
53
+ await app.startNextAndKillCurrent(async () => fakeApp)
54
+ fakeApp.untilExit = Promise.resolve({ exitCode: 1, signal: 9 })
55
+ await expect(app.untilExit()).resolves.toStrictEqual({
56
+ exitCode: 1,
57
+ signal: 9,
58
+ } satisfies ExitInfo)
59
+ })
60
+
61
+ it("when the application throws an error, the error is propagated", async () => {
62
+ const app = new TestDisposableSingleApplication()
63
+ await app.startNextAndKillCurrent(async () => fakeApp)
64
+ fakeApp.untilExit = Promise.reject(new Error("fake error"))
65
+ await expect(app.untilExit()).rejects.toThrowError(new Error("fake error"))
66
+ })
67
+ })
68
+
48
69
  describe("disposing", () => {
49
70
  it("disposes the application when disposed", async () => {
50
71
  // it's important to make sure there are no dangling applications when
@@ -1,7 +1,7 @@
1
1
  import assert from "assert"
2
- import type { TerminalApplication } from "./TerminalApplication.js"
2
+ import type { ExitInfo, TerminalApplication } from "./TerminalApplication.js"
3
3
 
4
- export type StartableApplication = Pick<TerminalApplication, "write" | "processId" | "killAndWait">
4
+ export type StartableApplication = Pick<TerminalApplication, "write" | "processId" | "killAndWait" | "untilExit">
5
5
 
6
6
  /** A testable application that can be started, killed, and given input. For a
7
7
  * single instance of this interface, only a single instance can be running at
@@ -15,6 +15,14 @@ export class DisposableSingleApplication implements AsyncDisposable {
15
15
  this.application = await startNext()
16
16
  }
17
17
 
18
+ public async untilExit(): Promise<ExitInfo> {
19
+ assert(
20
+ this.application,
21
+ "The application not started yet. It makes no sense to wait for it to exit, so this looks like a bug."
22
+ )
23
+ return this.application.untilExit
24
+ }
25
+
18
26
  public async write(input: string): Promise<void> {
19
27
  assert(
20
28
  this.application,
@@ -6,6 +6,8 @@ import type { IPty } from "node-pty"
6
6
  import pty from "node-pty"
7
7
  import type { StartableApplication } from "./DisposableSingleApplication.js"
8
8
 
9
+ export type ExitInfo = { exitCode: number; signal: number | undefined }
10
+
9
11
  // NOTE separating stdout and stderr is not supported by node-pty
10
12
  // https://github.com/microsoft/node-pty/issues/71
11
13
  export class TerminalApplication implements StartableApplication {
@@ -15,7 +17,8 @@ export class TerminalApplication implements StartableApplication {
15
17
 
16
18
  private constructor(
17
19
  private readonly subProcess: IPty,
18
- public readonly onStdoutOrStderr: (data: string) => void
20
+ public readonly onStdoutOrStderr: (data: string) => void,
21
+ public readonly untilExit: Promise<ExitInfo>
19
22
  ) {
20
23
  this.processId = subProcess.pid
21
24
 
@@ -60,14 +63,23 @@ export class TerminalApplication implements StartableApplication {
60
63
  cols: dimensions.cols,
61
64
  rows: dimensions.rows,
62
65
  })
66
+ ptyProcess.onExit(({ exitCode, signal }) => {
67
+ console.log(`Child process exited with code ${exitCode} and signal ${signal}`)
68
+ })
63
69
 
64
70
  const processId = ptyProcess.pid
65
71
 
66
72
  if (!processId) {
67
73
  throw new Error("Failed to spawn child process")
68
74
  }
75
+ const untilExit = new Promise<ExitInfo>(resolve => {
76
+ ptyProcess.onExit(({ exitCode, signal }) => {
77
+ // console.log(`Child process ${processId} exited with code ${exitCode} and signal ${signal}`)
78
+ resolve({ exitCode, signal })
79
+ })
80
+ })
69
81
 
70
- return new TerminalApplication(ptyProcess, onStdoutOrStderr)
82
+ return new TerminalApplication(ptyProcess, onStdoutOrStderr, untilExit)
71
83
  }
72
84
 
73
85
  /** Write to the terminal's stdin. */