@tui-sandbox/library 7.2.1 → 7.4.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 (32) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/src/scripts/tui.js +107 -10
  3. package/dist/src/server/cypress-support/contents.js +3 -0
  4. package/dist/src/server/cypress-support/contents.test.js +3 -0
  5. package/dist/src/server/neovim/NeovimApplication.d.ts +3 -1
  6. package/dist/src/server/neovim/NeovimApplication.js +6 -0
  7. package/dist/src/server/neovim/NeovimJavascriptApiClient.js +2 -2
  8. package/dist/src/server/neovim/environment/createTempDir.d.ts +2 -2
  9. package/dist/src/server/neovim/index.d.ts +6 -3
  10. package/dist/src/server/neovim/index.js +13 -2
  11. package/dist/src/server/server.d.ts +2 -2
  12. package/dist/src/server/server.js +3 -3
  13. package/dist/src/server/updateTestdirectorySchemaFile.d.ts +6 -2
  14. package/dist/src/server/utilities/DisposableSingleApplication.d.ts +3 -2
  15. package/dist/src/server/utilities/DisposableSingleApplication.js +4 -0
  16. package/dist/src/server/utilities/DisposableSingleApplication.test.js +18 -0
  17. package/dist/src/server/utilities/TerminalApplication.d.ts +5 -0
  18. package/dist/src/server/utilities/TerminalApplication.js +13 -2
  19. package/dist/tsconfig.tsbuildinfo +1 -1
  20. package/package.json +5 -5
  21. package/src/scripts/tui.ts +58 -11
  22. package/src/server/cypress-support/contents.test.ts +3 -0
  23. package/src/server/cypress-support/contents.ts +3 -0
  24. package/src/server/neovim/NeovimApplication.ts +12 -2
  25. package/src/server/neovim/NeovimJavascriptApiClient.ts +2 -2
  26. package/src/server/neovim/environment/createTempDir.ts +2 -2
  27. package/src/server/neovim/index.ts +17 -5
  28. package/src/server/server.ts +5 -5
  29. package/src/server/updateTestdirectorySchemaFile.ts +7 -2
  30. package/src/server/utilities/DisposableSingleApplication.test.ts +23 -2
  31. package/src/server/utilities/DisposableSingleApplication.ts +10 -2
  32. package/src/server/utilities/TerminalApplication.ts +14 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tui-sandbox/library",
3
- "version": "7.2.1",
3
+ "version": "7.4.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,7 +22,7 @@
22
22
  "node-pty": "1.0.0",
23
23
  "prettier": "3.4.1",
24
24
  "tsx": "4.19.2",
25
- "type-fest": "4.29.0",
25
+ "type-fest": "4.30.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.10.0",
34
+ "@types/node": "22.10.1",
35
35
  "nodemon": "3.1.7",
36
- "vite": "6.0.1",
37
- "vitest": "2.1.6"
36
+ "vite": "6.0.2",
37
+ "vitest": "2.1.8"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "cypress": "^13",
@@ -1,38 +1,85 @@
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
+ directories: {
22
+ testEnvironmentPath: path.join(cwd, "test-environment/"),
23
+ outputFilePath: path.join(cwd, outputFileName),
24
+ },
25
+ port: process.env["PORT"] ? parseInt(process.env["PORT"]) : 3000,
26
+ } satisfies TestServerConfig
27
+
28
+ // the arguments passed to this script start at index 2
29
+ const args = process.argv.slice(2)
30
+
31
+ if (args[0] === "neovim") {
32
+ if (!(args[1] === "exec" && args.length === 3)) {
33
+ showUsageAndExit()
34
+ }
35
+
36
+ const command = args[2]
37
+ assert(command, "No command provided")
38
+
39
+ {
40
+ // automatically dispose of the neovim instance when done
41
+ await using app = new NeovimApplication(config.directories.testEnvironmentPath)
42
+ app.events.on("stdout" satisfies StdoutOrStderrMessage, data => {
43
+ console.log(` neovim output: ${data}`)
44
+ })
45
+ const testDirectory = await prepareNewTestDirectory(config.directories)
46
+ await app.startNextAndKillCurrent(
47
+ testDirectory,
48
+ { filename: "empty.txt", headlessCmd: command },
49
+ { cols: 80, rows: 24 }
50
+ )
51
+ await app.application.untilExit()
52
+ }
53
+
54
+ process.exit(0)
55
+ }
56
+
57
+ if (args[0] !== "start") {
58
+ showUsageAndExit()
59
+ }
22
60
  console.log(`🚀 Starting test server in ${cwd} - this should be the root of your integration-tests directory 🤞🏻`)
23
61
  await stat(path.join(cwd, outputFileName))
24
62
 
25
63
  try {
26
- const config = {
27
- testEnvironmentPath: path.join(cwd, "test-environment/"),
28
- outputFilePath: path.join(cwd, outputFileName),
29
- } satisfies TestServerConfig
30
64
  await createCypressSupportFile({
31
65
  cypressSupportDirectoryPath: path.join(cwd, "cypress", "support"),
32
66
  supportFileName: "tui-sandbox.ts",
33
67
  })
34
- await updateTestdirectorySchemaFile(config)
68
+ await updateTestdirectorySchemaFile(config.directories)
35
69
  await startTestServer(config)
36
70
  } catch (e) {
37
71
  console.error(e)
38
72
  }
73
+
74
+ function showUsageAndExit() {
75
+ console.log(
76
+ [
77
+ //
78
+ `Usage (pick one):`,
79
+ ` tui start`,
80
+ ` tui neovim exec '<ex-command>'`,
81
+ ].join("\n")
82
+ )
83
+
84
+ process.exit(1)
85
+ }
@@ -96,6 +96,9 @@ it("should return the expected contents", async () => {
96
96
 
97
97
  runLuaCode(input: LuaCodeClientInput): Chainable<RunLuaCodeOutput>
98
98
 
99
+ /** Run an ex command in neovim.
100
+ * @example "echo expand('%:.')" current file, relative to the cwd
101
+ */
99
102
  runExCommand(input: ExCommandClientInput): Chainable<RunExCommandOutput>
100
103
  }
101
104
  }
@@ -99,6 +99,9 @@ declare global {
99
99
 
100
100
  runLuaCode(input: LuaCodeClientInput): Chainable<RunLuaCodeOutput>
101
101
 
102
+ /** Run an ex command in neovim.
103
+ * @example "echo expand('%:.')" current file, relative to the cwd
104
+ */
102
105
  runExCommand(input: ExCommandClientInput): Chainable<RunExCommandOutput>
103
106
  }
104
107
  }
@@ -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
  }
@@ -6,10 +6,10 @@ import { access, mkdir, mkdtemp } from "fs/promises"
6
6
  import path from "path"
7
7
  import { convertDree, getDirectoryTree } from "../../dirtree/index.js"
8
8
  import type { TestDirectory } from "../../types.js"
9
- import type { TestServerConfig } from "../../updateTestdirectorySchemaFile.js"
9
+ import type { DirectoriesConfig } from "../../updateTestdirectorySchemaFile.js"
10
10
  import { updateTestdirectorySchemaFile } from "../../updateTestdirectorySchemaFile.js"
11
11
 
12
- export async function createTempDir(config: TestServerConfig): Promise<TestDirectory> {
12
+ export async function createTempDir(config: DirectoriesConfig): Promise<TestDirectory> {
13
13
  try {
14
14
  const dir = await createUniqueDirectory(config.testEnvironmentPath)
15
15
 
@@ -10,16 +10,20 @@ import type {
10
10
  StartNeovimGenericArguments,
11
11
  TestDirectory,
12
12
  } from "../types.js"
13
- import type { TestServerConfig } from "../updateTestdirectorySchemaFile.js"
13
+ import type { DirectoriesConfig } 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")
@@ -46,18 +53,23 @@ export async function start(
46
53
  options: StartNeovimGenericArguments,
47
54
  terminalDimensions: TerminalDimensions,
48
55
  tabId: TabId,
49
- config: TestServerConfig
56
+ config: DirectoriesConfig
50
57
  ): Promise<TestDirectory> {
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: DirectoriesConfig): 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(
@@ -5,7 +5,7 @@ import { z } from "zod"
5
5
  import { trpc } from "./connection/trpc.js"
6
6
  import * as neovim from "./neovim/index.js"
7
7
  import { TestServer } from "./TestServer.js"
8
- import type { TestServerConfig } from "./updateTestdirectorySchemaFile.js"
8
+ import type { DirectoriesConfig, TestServerConfig } from "./updateTestdirectorySchemaFile.js"
9
9
  import { applicationAvailable } from "./utilities/applicationAvailable.js"
10
10
  import { tabIdSchema } from "./utilities/tabId.js"
11
11
 
@@ -35,7 +35,7 @@ export type ExCommandInput = z.infer<typeof exCommandInputSchema>
35
35
 
36
36
  /** @private */
37
37
  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
38
- export async function createAppRouter(config: TestServerConfig) {
38
+ export async function createAppRouter(config: DirectoriesConfig) {
39
39
  if (!(await applicationAvailable("nvim"))) {
40
40
  throw new Error("Neovim is not installed. Please install Neovim (nvim).")
41
41
  }
@@ -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)
@@ -99,9 +99,9 @@ export type RouterInput = inferRouterInputs<AppRouter>
99
99
 
100
100
  export async function startTestServer(config: TestServerConfig): Promise<TestServer> {
101
101
  const testServer = new TestServer({
102
- port: 3000,
102
+ port: config.port,
103
103
  })
104
- const appRouter = await createAppRouter(config)
104
+ const appRouter = await createAppRouter(config.directories)
105
105
  await testServer.startAndRun(appRouter)
106
106
 
107
107
  return testServer
@@ -1,17 +1,22 @@
1
1
  import { readFileSync, writeFileSync } from "fs"
2
2
  import { buildTestDirectorySchema } from "./dirtree/index.js"
3
3
 
4
- export type TestServerConfig = {
4
+ export type DirectoriesConfig = {
5
5
  testEnvironmentPath: string
6
6
  outputFilePath: string
7
7
  }
8
8
 
9
+ export type TestServerConfig = {
10
+ directories: DirectoriesConfig
11
+ port: number
12
+ }
13
+
9
14
  export type UpdateTestdirectorySchemaFileResult = "updated" | "did-nothing"
10
15
 
11
16
  export async function updateTestdirectorySchemaFile({
12
17
  testEnvironmentPath,
13
18
  outputFilePath,
14
- }: TestServerConfig): Promise<UpdateTestdirectorySchemaFileResult> {
19
+ }: DirectoriesConfig): Promise<UpdateTestdirectorySchemaFileResult> {
15
20
  const newSchema: string = await buildTestDirectorySchema(testEnvironmentPath)
16
21
  let oldSchema = ""
17
22
 
@@ -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. */