@tui-sandbox/library 9.0.1 → 9.1.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 (39) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/browser/assets/{index-DroaKLT0.js → index-pw1tOGNt.js} +6 -6
  3. package/dist/browser/index.html +1 -1
  4. package/dist/src/browser/neovim-client.d.ts +5 -0
  5. package/dist/src/browser/neovim-client.js +17 -6
  6. package/dist/src/client/index.d.ts +2 -1
  7. package/dist/src/client/index.js +2 -1
  8. package/dist/src/client/{terminal-client.d.ts → neovim-terminal-client.d.ts} +1 -1
  9. package/dist/src/client/{terminal-client.js → neovim-terminal-client.js} +3 -3
  10. package/dist/src/client/terminal-terminal-client.d.ts +14 -0
  11. package/dist/src/client/terminal-terminal-client.js +72 -0
  12. package/dist/src/server/cypress-support/contents.js +23 -1
  13. package/dist/src/server/dirtree/index.test.js +2 -0
  14. package/dist/src/server/neovim/NeovimApplication.d.ts +1 -1
  15. package/dist/src/server/neovim/environment/createTempDir.test.js +5 -5
  16. package/dist/src/server/server.d.ts +44 -4
  17. package/dist/src/server/server.js +24 -0
  18. package/dist/src/server/terminal/TerminalTestApplication.d.ts +22 -0
  19. package/dist/src/server/terminal/TerminalTestApplication.js +60 -0
  20. package/dist/src/server/terminal/index.d.ts +12 -0
  21. package/dist/src/server/terminal/index.js +40 -0
  22. package/dist/src/server/types.d.ts +5 -0
  23. package/dist/src/server/utilities/TerminalApplication.d.ts +1 -0
  24. package/dist/src/server/utilities/TerminalApplication.js +10 -5
  25. package/dist/tsconfig.tsbuildinfo +1 -1
  26. package/package.json +9 -9
  27. package/src/browser/neovim-client.ts +26 -6
  28. package/src/client/index.ts +2 -1
  29. package/src/client/{terminal-client.ts → neovim-terminal-client.ts} +3 -3
  30. package/src/client/terminal-terminal-client.ts +86 -0
  31. package/src/server/cypress-support/contents.ts +23 -1
  32. package/src/server/dirtree/index.test.ts +2 -0
  33. package/src/server/neovim/NeovimApplication.ts +3 -3
  34. package/src/server/neovim/environment/createTempDir.test.ts +5 -5
  35. package/src/server/server.ts +34 -0
  36. package/src/server/terminal/TerminalTestApplication.ts +98 -0
  37. package/src/server/terminal/index.ts +62 -0
  38. package/src/server/types.ts +13 -0
  39. package/src/server/utilities/TerminalApplication.ts +10 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tui-sandbox/library",
3
- "version": "9.0.1",
3
+ "version": "9.1.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.718",
12
- "@trpc/server": "11.0.0-rc.718",
11
+ "@trpc/client": "11.0.0-rc.768",
12
+ "@trpc/server": "11.0.0-rc.768",
13
13
  "@xterm/addon-attach": "0.11.0",
14
14
  "@xterm/addon-fit": "0.10.0",
15
15
  "@xterm/xterm": "5.5.0",
@@ -20,21 +20,21 @@
20
20
  "express": "4.21.2",
21
21
  "neovim": "5.3.0",
22
22
  "node-pty": "1.0.0",
23
- "prettier": "3.4.2",
23
+ "prettier": "3.5.1",
24
24
  "tsx": "4.19.2",
25
- "type-fest": "4.33.0",
25
+ "type-fest": "4.34.1",
26
26
  "winston": "3.17.0",
27
- "zod": "3.24.1"
27
+ "zod": "3.24.2"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@runtyping/zod": "2.1.1",
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.10",
34
+ "@types/node": "22.13.4",
35
35
  "nodemon": "3.1.9",
36
- "vite": "6.0.11",
37
- "vitest": "3.0.4"
36
+ "vite": "6.1.0",
37
+ "vitest": "3.0.5"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "cypress": "^13 || ^14",
@@ -1,5 +1,7 @@
1
- import { TerminalClient } from "../client/index.js"
1
+ import { TerminalClient as NeovimTerminalClient } from "../client/index.js"
2
+ import { TerminalTerminalClient } from "../client/terminal-terminal-client.js"
2
3
  import type { BlockingCommandClientInput, ExCommandClientInput, LuaCodeClientInput } from "../server/server.js"
4
+ import type { StartTerminalGenericArguments } from "../server/terminal/TerminalTestApplication.js"
3
5
  import type {
4
6
  BlockingShellCommandOutput,
5
7
  RunExCommandOutput,
@@ -7,13 +9,16 @@ import type {
7
9
  StartNeovimGenericArguments,
8
10
  TestDirectory,
9
11
  } from "../server/types.js"
12
+ import { Lazy } from "../server/utilities/Lazy.js"
10
13
 
11
14
  const app = document.querySelector<HTMLElement>("#app")
12
15
  if (!app) {
13
16
  throw new Error("No app element found")
14
17
  }
15
18
 
16
- const client = new TerminalClient(app)
19
+ // limitation: right now only one client can be used in the same test
20
+ const neovimClient = new Lazy(() => new NeovimTerminalClient(app))
21
+ const terminalClient = new Lazy(() => new TerminalTerminalClient(app))
17
22
 
18
23
  export type GenericNeovimBrowserApi = {
19
24
  runBlockingShellCommand(input: BlockingCommandClientInput): Promise<BlockingShellCommandOutput>
@@ -24,7 +29,8 @@ export type GenericNeovimBrowserApi = {
24
29
 
25
30
  /** Entrypoint for the test runner (cypress) */
26
31
  window.startNeovim = async function (startArgs?: StartNeovimGenericArguments): Promise<GenericNeovimBrowserApi> {
27
- const testDirectory = await client.startNeovim({
32
+ const neovim = neovimClient.get()
33
+ const testDirectory = await neovim.startNeovim({
28
34
  additionalEnvironmentVariables: startArgs?.additionalEnvironmentVariables,
29
35
  filename: startArgs?.filename ?? "initial-file.txt",
30
36
  startupScriptModifications: startArgs?.startupScriptModifications ?? [],
@@ -32,13 +38,13 @@ window.startNeovim = async function (startArgs?: StartNeovimGenericArguments): P
32
38
 
33
39
  const neovimBrowserApi: GenericNeovimBrowserApi = {
34
40
  runBlockingShellCommand(input: BlockingCommandClientInput): Promise<BlockingShellCommandOutput> {
35
- return client.runBlockingShellCommand(input)
41
+ return neovim.runBlockingShellCommand(input)
36
42
  },
37
43
  runLuaCode(input) {
38
- return client.runLuaCode(input)
44
+ return neovim.runLuaCode(input)
39
45
  },
40
46
  runExCommand(input) {
41
- return client.runExCommand(input)
47
+ return neovim.runExCommand(input)
42
48
  },
43
49
  dir: testDirectory,
44
50
  }
@@ -49,5 +55,19 @@ window.startNeovim = async function (startArgs?: StartNeovimGenericArguments): P
49
55
  declare global {
50
56
  interface Window {
51
57
  startNeovim(startArguments?: StartNeovimGenericArguments): Promise<GenericNeovimBrowserApi>
58
+ startTerminalApplication(args: StartTerminalGenericArguments): Promise<GenericTerminalBrowserApi>
52
59
  }
53
60
  }
61
+
62
+ export type GenericTerminalBrowserApi = {
63
+ dir: TestDirectory
64
+ }
65
+
66
+ /** Entrypoint for the test runner (cypress) */
67
+ window.startTerminalApplication = async function (
68
+ args: StartTerminalGenericArguments
69
+ ): Promise<GenericTerminalBrowserApi> {
70
+ const terminal = terminalClient.get()
71
+ const testDirectory = await terminal.startTerminalApplication(args)
72
+ return { dir: testDirectory }
73
+ }
@@ -1,4 +1,5 @@
1
1
  // This is the public client api. Semantic versioning will be applied to this.
2
2
 
3
3
  export { rgbify } from "./color-utilities.js"
4
- export { TerminalClient } from "./terminal-client.js"
4
+ export { NeovimTerminalClient as TerminalClient } from "./neovim-terminal-client.js"
5
+ export { TerminalTerminalClient } from "./terminal-terminal-client.js"
@@ -19,7 +19,7 @@ import { getTabId, startTerminal } from "./websocket-client.js"
19
19
 
20
20
  /** Manages the terminal state in the browser as well as the (browser's)
21
21
  * connection to the server side terminal application api. */
22
- export class TerminalClient {
22
+ export class NeovimTerminalClient {
23
23
  private readonly ready: Promise<void>
24
24
  private readonly tabId: { tabId: string }
25
25
  private readonly terminal: Terminal
@@ -59,7 +59,7 @@ export class TerminalClient {
59
59
  // start listening to Neovim stdout - this will take some (short) amount of
60
60
  // time to complete
61
61
  this.ready = new Promise<void>(resolve => {
62
- console.log("Subscribing to Neovim stdout")
62
+ console.log("Subscribing to stdout")
63
63
  trpc.neovim.onStdout.subscribe(
64
64
  { client: tabId },
65
65
  {
@@ -70,7 +70,7 @@ export class TerminalClient {
70
70
  terminal.write(data)
71
71
  },
72
72
  onError(err: unknown) {
73
- console.error(`Error from Neovim`, err)
73
+ console.error(`Error from the application`, err)
74
74
  },
75
75
  }
76
76
  )
@@ -0,0 +1,86 @@
1
+ import { createTRPCClient, httpBatchLink, splitLink, unstable_httpSubscriptionLink } from "@trpc/client"
2
+ import type { Terminal } from "@xterm/xterm"
3
+ import "@xterm/xterm/css/xterm.css"
4
+ import type { AppRouter } from "../server/server.js"
5
+ import type { StartTerminalGenericArguments } from "../server/terminal/TerminalTestApplication.js"
6
+ import type { TestDirectory } from "../server/types.js"
7
+ import "./style.css"
8
+ import { getTabId, startTerminal } from "./websocket-client.js"
9
+
10
+ /** Manages the terminal state in the browser as well as the (browser's)
11
+ * connection to the server side terminal application api. */
12
+ export class TerminalTerminalClient {
13
+ private readonly ready: Promise<void>
14
+ private readonly tabId: { tabId: string }
15
+ private readonly terminal: Terminal
16
+ private readonly trpc: ReturnType<typeof createTRPCClient<AppRouter>>
17
+
18
+ constructor(app: HTMLElement) {
19
+ const trpc = createTRPCClient<AppRouter>({
20
+ links: [
21
+ splitLink({
22
+ condition: operation => operation.type === "subscription",
23
+ true: unstable_httpSubscriptionLink({
24
+ url: "/trpc",
25
+ }),
26
+ false: httpBatchLink({
27
+ url: "/trpc",
28
+ }),
29
+ }),
30
+ ],
31
+ })
32
+ this.trpc = trpc
33
+
34
+ this.tabId = getTabId()
35
+ const tabId = this.tabId
36
+
37
+ const terminal = startTerminal(app, {
38
+ onMouseEvent(data: string) {
39
+ void trpc.terminal.sendStdin.mutate({ tabId, data }).catch((error: unknown) => {
40
+ console.error(`Error sending mouse event`, error)
41
+ })
42
+ },
43
+ onKeyPress(event) {
44
+ void trpc.terminal.sendStdin.mutate({ tabId, data: event.key })
45
+ },
46
+ })
47
+ this.terminal = terminal
48
+
49
+ // start listening to Neovim stdout - this will take some (short) amount of
50
+ // time to complete
51
+ this.ready = new Promise<void>(resolve => {
52
+ console.log("Subscribing to stdout")
53
+ trpc.terminal.onStdout.subscribe(
54
+ { client: tabId },
55
+ {
56
+ onStarted() {
57
+ resolve()
58
+ },
59
+ onData(data: string) {
60
+ terminal.write(data)
61
+ },
62
+ onError(err: unknown) {
63
+ console.error(`Error from the application`, err)
64
+ },
65
+ }
66
+ )
67
+ })
68
+ }
69
+
70
+ public async startTerminalApplication(args: StartTerminalGenericArguments): Promise<TestDirectory> {
71
+ await this.ready
72
+ const testDirectory = await this.trpc.terminal.start.mutate({
73
+ tabId: this.tabId,
74
+ startTerminalArguments: {
75
+ additionalEnvironmentVariables: args.additionalEnvironmentVariables,
76
+ commandToRun: args.commandToRun,
77
+ terminalDimensions: {
78
+ cols: this.terminal.cols,
79
+ rows: this.terminal.rows,
80
+ },
81
+ },
82
+ })
83
+
84
+ return testDirectory
85
+ }
86
+ }
@@ -10,7 +10,10 @@ export async function createCypressSupportFileContents(): Promise<string> {
10
10
  //
11
11
  // This file is autogenerated by tui-sandbox. Do not edit it directly.
12
12
  //
13
- import type { GenericNeovimBrowserApi } from "@tui-sandbox/library/dist/src/browser/neovim-client"
13
+ import type {
14
+ GenericNeovimBrowserApi,
15
+ GenericTerminalBrowserApi,
16
+ } from "@tui-sandbox/library/dist/src/browser/neovim-client"
14
17
  import type {
15
18
  BlockingCommandClientInput,
16
19
  ExCommandClientInput,
@@ -23,9 +26,19 @@ import type {
23
26
  StartNeovimGenericArguments,
24
27
  TestDirectory,
25
28
  } from "@tui-sandbox/library/dist/src/server/types"
29
+ import type { StartTerminalGenericArguments } from "@tui-sandbox/library/src/server/terminal/TerminalTestApplication"
26
30
  import type { OverrideProperties } from "type-fest"
27
31
  import type { MyTestDirectory, MyTestDirectoryFile } from "../../MyTestDirectory"
28
32
 
33
+ export type TerminalTestApplicationContext = {
34
+ /** Types text into the terminal, making the terminal application receive the
35
+ * keystrokes as input. Requires the application to be running. */
36
+ typeIntoTerminal(text: string, options?: Partial<Cypress.TypeOptions>): void
37
+
38
+ /** The test directory, providing type-safe access to its file and directory structure */
39
+ dir: TestDirectory<MyTestDirectory>
40
+ }
41
+
29
42
  /** The api that can be used in tests after a Neovim instance has been started. */
30
43
  export type NeovimContext = {
31
44
  /** Types text into the terminal, making the terminal application receive
@@ -93,6 +106,14 @@ Cypress.Commands.add("startNeovim", (startArguments?: MyStartNeovimServerArgumen
93
106
  })
94
107
  })
95
108
 
109
+ Cypress.Commands.add("startTerminalApplication", (args: StartTerminalGenericArguments) => {
110
+ cy.window().then(async win => {
111
+ const api: GenericTerminalBrowserApi = await win.startTerminalApplication(args)
112
+
113
+ return api
114
+ })
115
+ })
116
+
96
117
  Cypress.Commands.add("typeIntoTerminal", (text: string, options?: Partial<Cypress.TypeOptions>) => {
97
118
  // the syntax for keys is described here:
98
119
  // https://docs.cypress.io/api/commands/type
@@ -112,6 +133,7 @@ declare global {
112
133
  namespace Cypress {
113
134
  interface Chainable {
114
135
  startNeovim(args?: MyStartNeovimServerArguments): Chainable<NeovimContext>
136
+ startTerminalApplication(args: StartTerminalGenericArguments): Chainable<TerminalTestApplicationContext>
115
137
 
116
138
  /** Types text into the terminal, making the terminal application receive
117
139
  * the keystrokes as input. Requires neovim to be running. */
@@ -39,6 +39,7 @@ describe("dirtree", () => {
39
39
  name: z.literal("test-environment/"),
40
40
  type: z.literal("directory"),
41
41
  contents: z.object({
42
+ ".bashrc": z.object({ name: z.literal(".bashrc"), type: z.literal("file") }),
42
43
  ".config": z.object({
43
44
  name: z.literal(".config/"),
44
45
  type: z.literal("directory"),
@@ -127,6 +128,7 @@ describe("dirtree", () => {
127
128
  export type MyDirectoryTree = MyDirectoryTreeContentsSchemaType["contents"]
128
129
 
129
130
  export const testDirectoryFiles = z.enum([
131
+ ".bashrc",
130
132
  ".config/.gitkeep",
131
133
  ".config/nvim/init.lua",
132
134
  ".config/nvim/prepare.lua",
@@ -5,7 +5,7 @@ import { access } from "fs/promises"
5
5
  import type { NeovimClient as NeovimApiClient } from "neovim"
6
6
  import { tmpdir } from "os"
7
7
  import path, { join } from "path"
8
- import type { TestDirectory } from "../types.js"
8
+ import type { TestDirectory, TestEnvironmentCommonEnvironmentVariables } from "../types.js"
9
9
  import { DisposableSingleApplication } from "../utilities/DisposableSingleApplication.js"
10
10
  import type { Lazy } from "../utilities/Lazy.js"
11
11
  import { TerminalApplication } from "../utilities/TerminalApplication.js"
@@ -77,7 +77,7 @@ type ResettableState = {
77
77
  client: Lazy<Promise<NeovimApiClient>>
78
78
  }
79
79
 
80
- export class NeovimApplication {
80
+ export class NeovimApplication implements AsyncDisposable {
81
81
  public state: ResettableState | undefined
82
82
  public readonly events: EventEmitter
83
83
 
@@ -190,7 +190,7 @@ export class NeovimApplication {
190
190
  XDG_DATA_HOME: join(testDirectory.testEnvironmentPath, ".repro", "data"),
191
191
 
192
192
  ...additionalEnvironmentVariables,
193
- }
193
+ } satisfies TestEnvironmentCommonEnvironmentVariables
194
194
  }
195
195
 
196
196
  async [Symbol.asyncDispose](): Promise<void> {
@@ -13,12 +13,12 @@ class TempDirectory implements Disposable {
13
13
 
14
14
  public static create(): TempDirectory {
15
15
  const tmp = fs.mkdtempSync("test-temp-dir-" satisfies TestTempDirPrefix)
16
- return new TempDirectory(tmp)
16
+ const absolutePath = nodePath.resolve(tmp)
17
+ return new TempDirectory(absolutePath)
17
18
  }
18
19
 
19
20
  [Symbol.dispose](): void {
20
- // eslint-disable-next-line no-empty-function
21
- fs.rm(this.path, { recursive: true }, () => {})
21
+ fs.rmdirSync(this.path, { recursive: true, maxRetries: 5 })
22
22
  }
23
23
  }
24
24
 
@@ -32,6 +32,6 @@ it("should create a temp dir with no contents", async () => {
32
32
 
33
33
  expect(result.contents).toEqual({})
34
34
  expect(result.testEnvironmentPath).toEqual(dir.path)
35
- expect(result.testEnvironmentPath.startsWith("test-temp-dir-" satisfies TestTempDirPrefix)).toBeTruthy()
36
- expect(result.testEnvironmentPathRelative.startsWith("testdirs" satisfies TestDirsPath)).toBeTruthy()
35
+ expect(result.testEnvironmentPath.includes("test-temp-dir-" satisfies TestTempDirPrefix)).toBeTruthy()
36
+ expect(result.testEnvironmentPathRelative.includes("testdirs" satisfies TestDirsPath)).toBeTruthy()
37
37
  })
@@ -4,6 +4,7 @@ import type { Except } from "type-fest"
4
4
  import { z } from "zod"
5
5
  import { trpc } from "./connection/trpc.js"
6
6
  import * as neovim from "./neovim/index.js"
7
+ import * as terminal from "./terminal/index.js"
7
8
  import { TestServer } from "./TestServer.js"
8
9
  import type { DirectoriesConfig, TestServerConfig } from "./updateTestdirectorySchemaFile.js"
9
10
  import { applicationAvailable } from "./utilities/applicationAvailable.js"
@@ -45,6 +46,39 @@ export async function createAppRouter(config: DirectoriesConfig) {
45
46
  }
46
47
 
47
48
  const appRouter = trpc.router({
49
+ terminal: trpc.router({
50
+ onStdout: trpc.procedure.input(z.object({ client: tabIdSchema })).subscription(options => {
51
+ return terminal.initializeStdout(options.input, options.signal, config.testEnvironmentPath)
52
+ }),
53
+
54
+ start: trpc.procedure
55
+ .input(
56
+ z.object({
57
+ tabId: tabIdSchema,
58
+ startTerminalArguments: z.object({
59
+ commandToRun: z.array(z.string()),
60
+ additionalEnvironmentVariables: z.record(z.string()).optional(),
61
+ terminalDimensions: z.object({
62
+ cols: z.number(),
63
+ rows: z.number(),
64
+ }),
65
+ }),
66
+ })
67
+ )
68
+ .mutation(options => {
69
+ return terminal.start(
70
+ options.input.startTerminalArguments.terminalDimensions,
71
+ options.input.startTerminalArguments.commandToRun,
72
+ options.input.tabId,
73
+ config
74
+ )
75
+ }),
76
+
77
+ sendStdin: trpc.procedure.input(z.object({ tabId: tabIdSchema, data: z.string() })).mutation(options => {
78
+ return terminal.sendStdin(options.input)
79
+ }),
80
+ }),
81
+
48
82
  neovim: trpc.router({
49
83
  start: trpc.procedure
50
84
  .input(
@@ -0,0 +1,98 @@
1
+ import assert from "assert"
2
+ import { exec } from "child_process"
3
+ import EventEmitter from "events"
4
+ import { join } from "path"
5
+ import type { StdoutOrStderrMessage, TerminalDimensions } from "../neovim/NeovimApplication.js"
6
+ import type { TestDirectory, TestEnvironmentCommonEnvironmentVariables } from "../types.js"
7
+ import { DisposableSingleApplication } from "../utilities/DisposableSingleApplication.js"
8
+ import { TerminalApplication } from "../utilities/TerminalApplication.js"
9
+
10
+ type ResettableState = {
11
+ testDirectory: TestDirectory
12
+ }
13
+
14
+ export type StartTerminalGenericArguments = {
15
+ commandToRun: string[]
16
+ additionalEnvironmentVariables?: Record<string, string> | undefined
17
+ }
18
+
19
+ export default class TerminalTestApplication implements AsyncDisposable {
20
+ public state: ResettableState | undefined
21
+ public readonly events: EventEmitter
22
+
23
+ public constructor(
24
+ private readonly testEnvironmentPath: string,
25
+ public readonly application: DisposableSingleApplication = new DisposableSingleApplication()
26
+ ) {
27
+ this.events = new EventEmitter()
28
+ }
29
+
30
+ public async startNextAndKillCurrent(
31
+ testDirectory: TestDirectory,
32
+ startArgs: StartTerminalGenericArguments,
33
+ terminalDimensions: TerminalDimensions
34
+ ): Promise<void> {
35
+ await this[Symbol.asyncDispose]()
36
+ assert(
37
+ this.state === undefined,
38
+ "TerminalTestApplication state should be undefined after disposing so that no previous state is reused."
39
+ )
40
+
41
+ const command = startArgs.commandToRun[0]
42
+ assert(command, "No command to run was provided.")
43
+ // TODO could check if the command is executable
44
+ const terminalArguments = startArgs.commandToRun.slice(1)
45
+
46
+ const stdout = this.events
47
+
48
+ await this.application.startNextAndKillCurrent(async () => {
49
+ const env = this.getEnvironmentVariables(testDirectory, startArgs.additionalEnvironmentVariables)
50
+ return TerminalApplication.start({
51
+ command,
52
+ args: terminalArguments,
53
+
54
+ cwd: this.testEnvironmentPath,
55
+ env: env,
56
+ dimensions: terminalDimensions,
57
+
58
+ onStdoutOrStderr(data) {
59
+ data satisfies string
60
+ stdout.emit("stdout" satisfies StdoutOrStderrMessage, data)
61
+ },
62
+ })
63
+ })
64
+
65
+ const processId = this.application.processId()
66
+ assert(
67
+ processId !== undefined,
68
+ "TerminalApplication was started without a process ID. This is a bug - please open an issue."
69
+ )
70
+
71
+ this.state = { testDirectory }
72
+
73
+ console.log(`🚀 Started Terminal instance ${processId}`)
74
+ }
75
+
76
+ public getEnvironmentVariables(
77
+ testDirectory: TestDirectory,
78
+ additionalEnvironmentVariables?: Record<string, string>
79
+ ): NodeJS.ProcessEnv {
80
+ return {
81
+ ...process.env,
82
+ HOME: testDirectory.rootPathAbsolute,
83
+ XDG_CONFIG_HOME: join(testDirectory.rootPathAbsolute, ".config"),
84
+ XDG_DATA_HOME: join(testDirectory.testEnvironmentPath, ".repro", "data"),
85
+ ...additionalEnvironmentVariables,
86
+ } satisfies TestEnvironmentCommonEnvironmentVariables
87
+ }
88
+
89
+ async [Symbol.asyncDispose](): Promise<void> {
90
+ await this.application[Symbol.asyncDispose]()
91
+
92
+ if (!this.state) return
93
+
94
+ exec(`rm -rf ${this.state.testDirectory.rootPathAbsolute}`)
95
+
96
+ this.state = undefined
97
+ }
98
+ }
@@ -0,0 +1,62 @@
1
+ import assert from "assert"
2
+ import "core-js/proposals/async-explicit-resource-management.js"
3
+ import { prepareNewTestDirectory } from "../neovim/index.js"
4
+ import type { TerminalDimensions } from "../neovim/NeovimApplication.js"
5
+ import type { DirectoriesConfig } from "../updateTestdirectorySchemaFile.js"
6
+ import { convertEventEmitterToAsyncGenerator } from "../utilities/generator.js"
7
+ import { Lazy } from "../utilities/Lazy.js"
8
+ import type { TabId } from "../utilities/tabId.js"
9
+ import TerminalTestApplication from "./TerminalTestApplication.js"
10
+
11
+ const terminals = new Map<TabId["tabId"], TerminalTestApplication>()
12
+ const resources: Lazy<AsyncDisposableStack> = new Lazy(() => {
13
+ return new AsyncDisposableStack()
14
+ })
15
+
16
+ export async function start(
17
+ terminalDimensions: TerminalDimensions,
18
+ commandToRun: string[],
19
+ tabId: TabId,
20
+ config: DirectoriesConfig
21
+ ): Promise<void> {
22
+ const app = terminals.get(tabId.tabId)
23
+ assert(app, `Terminal with tabId ${tabId.tabId} not found.`)
24
+ const testDirectory = await prepareNewTestDirectory(config)
25
+ await app.startNextAndKillCurrent(testDirectory, { commandToRun }, terminalDimensions)
26
+ }
27
+
28
+ export async function initializeStdout(
29
+ options: { client: TabId },
30
+ signal: AbortSignal | undefined,
31
+ testEnvironmentPath: string
32
+ ): Promise<AsyncGenerator<string, void, unknown>> {
33
+ const tabId = options.client.tabId
34
+ const app = terminals.get(tabId) ?? new TerminalTestApplication(testEnvironmentPath)
35
+ if (terminals.get(tabId) === undefined) {
36
+ terminals.set(tabId, app)
37
+ resources.get().adopt(app, async a => {
38
+ await a[Symbol.asyncDispose]()
39
+ })
40
+ }
41
+
42
+ const stdout = convertEventEmitterToAsyncGenerator(app.events, "stdout")
43
+ signal?.addEventListener("abort", () => {
44
+ void app[Symbol.asyncDispose]().finally(() => {
45
+ terminals.delete(tabId)
46
+ })
47
+ })
48
+
49
+ return stdout
50
+ }
51
+
52
+ export async function sendStdin(options: { tabId: TabId; data: string }): Promise<void> {
53
+ const tabId = options.tabId.tabId
54
+ const app = terminals.get(tabId)
55
+ assert(app !== undefined, `Terminal instance for clientId not found - cannot send stdin. Maybe it's not started yet?`)
56
+ assert(
57
+ app.application,
58
+ `Terminal application not found for client id ${options.tabId.tabId}. Maybe it's not started yet?`
59
+ )
60
+
61
+ await app.application.write(options.data)
62
+ }
@@ -31,6 +31,19 @@ export type TestDirectory<TContents extends object = object> = {
31
31
  contents: TContents
32
32
  }
33
33
 
34
+ export type TestEnvironmentCommonEnvironmentVariables = {
35
+ HOME: string
36
+
37
+ // this is needed so that the application being tested can load its
38
+ // configuration, emulating a common setup real users have
39
+ XDG_CONFIG_HOME: string
40
+
41
+ // the data directory is where the application stores its data. To prevent
42
+ // downloading a new set of plugins/whatever for each test, share the data
43
+ // directory.
44
+ XDG_DATA_HOME: string
45
+ }
46
+
34
47
  export type { StartNeovimGenericArguments } from "../server/neovim/NeovimApplication.js"
35
48
 
36
49
  export type BlockingShellCommandOutput =
@@ -18,7 +18,8 @@ export class TerminalApplication implements StartableApplication {
18
18
  private constructor(
19
19
  private readonly subProcess: IPty,
20
20
  public readonly onStdoutOrStderr: (data: string) => void,
21
- public readonly untilExit: Promise<ExitInfo>
21
+ public readonly untilExit: Promise<ExitInfo>,
22
+ public readonly name: string
22
23
  ) {
23
24
  this.processId = subProcess.pid
24
25
 
@@ -30,12 +31,11 @@ export class TerminalApplication implements StartableApplication {
30
31
 
31
32
  this.logger.debug(`started`)
32
33
 
33
- subProcess.onData(this.onStdoutOrStderr)
34
-
35
34
  subProcess.onExit(({ exitCode, signal }) => {
36
- this.logger.debug(
37
- `Child process ${this.processId} exited with code ${String(exitCode)} and signal ${String(signal)}`
38
- )
35
+ signal satisfies number | undefined
36
+ const msg = `Child process ${this.processId} (${this.name}) exited with code ${String(exitCode)} and signal ${String(signal)}`
37
+ this.onStdoutOrStderr(msg)
38
+ this.logger.debug(msg)
39
39
  })
40
40
  }
41
41
 
@@ -55,7 +55,8 @@ export class TerminalApplication implements StartableApplication {
55
55
  env?: NodeJS.ProcessEnv
56
56
  dimensions: ITerminalDimensions
57
57
  }): TerminalApplication {
58
- console.log(`Starting '${command} ${args.join(" ")}' in cwd '${cwd}'`)
58
+ console.log(`Starting '${command}' with args '${args.join(" ")}' in cwd '${cwd}'`)
59
+
59
60
  const ptyProcess = pty.spawn(command, args, {
60
61
  name: "xterm-color",
61
62
  cwd,
@@ -63,6 +64,7 @@ export class TerminalApplication implements StartableApplication {
63
64
  cols: dimensions.cols,
64
65
  rows: dimensions.rows,
65
66
  })
67
+ ptyProcess.onData(onStdoutOrStderr)
66
68
  ptyProcess.onExit(({ exitCode, signal }) => {
67
69
  console.log(`Child process exited with code ${exitCode} and signal ${signal}`)
68
70
  })
@@ -79,7 +81,7 @@ export class TerminalApplication implements StartableApplication {
79
81
  })
80
82
  })
81
83
 
82
- return new TerminalApplication(ptyProcess, onStdoutOrStderr, untilExit)
84
+ return new TerminalApplication(ptyProcess, onStdoutOrStderr, untilExit, ptyProcess.process satisfies string)
83
85
  }
84
86
 
85
87
  /** Write to the terminal's stdin. */