| 📦 Turbo JS 91999d1 k33g 12h ago | 1 | package jslang_test |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "os/exec" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/gdamore/tcell/v2" |
| 14 | |
| 15 | "rickub.com/turbo-editors/turbo-core/app" |
| 16 | "rickub.com/turbo-editors/turbo-core/buffer" |
| 17 | "rickub.com/turbo-editors/turbo-core/lsp" |
| 18 | "rickub.com/turbo-editors/turbo-core/syntax" |
| 19 | "rickub.com/turbo-editors/turbo-core/ui" |
| 20 | |
| 21 | "rickub.com/turbo-editors/turbo-js/internal/jslang" |
| 22 | ) |
| 23 | |
| 24 | // --- the editor, assembled -------------------------------------------------- |
| 25 | |
| 26 | func TestTheEditorCallsItselfTurboJS(t *testing.T) { |
| 27 | editor := newTestEditor(t) |
| 28 | |
| 29 | if got := editor.Profile().Name; got != jslang.Name { |
| 30 | t.Errorf("Profile().Name = %q, want %q", got, jslang.Name) |
| 31 | } |
| 32 | if got := editor.Profile().ProjectDir(); got != ".turbo-js" { |
| 33 | t.Errorf("ProjectDir() = %q, want %q", got, ".turbo-js") |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | func TestTheEditorColoursJavaScriptItOpens(t *testing.T) { |
| 38 | // The whole path in one test: Register taught the library this editor's |
| 39 | // JavaScript, the profile named the editor, and a .js file opened through |
| 40 | // the public API comes out coloured — by this scanner, which is what the |
| 41 | // regular expression proves. |
| 42 | root := t.TempDir() |
| 43 | path := filepath.Join(root, "main.js") |
| 44 | writeFile(t, path, "const re = /x/;\nconsole.log(re);\n") |
| 45 | |
| 46 | editor := newTestEditor(t) |
| 47 | editor.Open(path) |
| 48 | |
| 49 | if got := editor.ActiveView().Language(); got != jslang.Language { |
| 50 | t.Fatalf("the view colours the file as %q, want %q", got, jslang.Language) |
| 51 | } |
| 52 | spans := syntax.Highlight(jslang.Language, "const re = /x/;") |
| 53 | if len(spans[0]) == 0 { |
| 54 | t.Fatal("the registered JavaScript scanner colours nothing") |
| 55 | } |
| 56 | if !coloured(spans[0], 11, syntax.ClassChar) { |
| 57 | t.Errorf("/x/ is not coloured as a regular expression; the library's own scanner is still in charge: %v", spans[0]) |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | func TestTheEditorColoursPackageJSON(t *testing.T) { |
| 62 | root := t.TempDir() |
| 63 | path := filepath.Join(root, "package.json") |
| 64 | writeFile(t, path, "{\n \"name\": \"demo\"\n}\n") |
| 65 | |
| 66 | editor := newTestEditor(t) |
| 67 | editor.Open(path) |
| 68 | |
| 69 | if got := editor.ActiveView().Language(); got != jslang.LanguageJSON { |
| 70 | t.Errorf("package.json is coloured as %q, want %q", got, jslang.LanguageJSON) |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | func TestAScriptWithANodeShebangAndNoExtensionIsJavaScriptToo(t *testing.T) { |
| 75 | // A command-line tool has no extension; its first line is what identifies |
| 76 | // it, and the editor reads that line before choosing a scanner. |
| 77 | root := t.TempDir() |
| 78 | path := filepath.Join(root, "cli") |
| 79 | writeFile(t, path, "#!/usr/bin/env node\nconsole.log('hi');\n") |
| 80 | |
| 81 | editor := newTestEditor(t) |
| 82 | editor.Open(path) |
| 83 | |
| 84 | if got := editor.ActiveView().Language(); got != jslang.Language { |
| 85 | t.Errorf("a script opening with a node shebang is coloured as %q, want %q", got, jslang.Language) |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | func TestTheEditorDoesNotColourTheOtherEditorsLanguages(t *testing.T) { |
| 90 | // "JavaScript instead of Python" is the whole point of this editor being |
| 91 | // a separate one: a .py, .rs or .go file opens as plain text here — and |
| 92 | // so does TypeScript, which the reference documents as a boundary. |
| 93 | root := t.TempDir() |
| 94 | editor := newTestEditor(t) |
| 95 | |
| 96 | for name, source := range map[string]string{ |
| 97 | "main.py": "print('hi')\n", |
| 98 | "main.rs": "fn main() {}\n", |
| 99 | "main.go": "package main\n", |
| 100 | "main.ts": "const x: number = 1;\n", |
| 101 | } { |
| 102 | path := filepath.Join(root, name) |
| 103 | writeFile(t, path, source) |
| 104 | editor.Open(path) |
| 105 | |
| 106 | if got := editor.ActiveView().Language(); got != syntax.LanguageNone { |
| 107 | t.Errorf("%s is coloured as %q; Turbo JS registers JavaScript and JSON, nothing else", name, got) |
| 108 | } |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | func TestAProjectsOwnFilesAreStillColouredByTheLibrary(t *testing.T) { |
| 113 | // A README, a compose file, a Dockerfile and a web page are what a Node |
| 114 | // project is made of besides its scripts, and turbo-core colours all four |
| 115 | // without this editor doing anything. That the inherited languages |
| 116 | // survive registration is worth one test, because syntax.Register writes |
| 117 | // into package-level state — and this editor replaces one of them. |
| 118 | root := t.TempDir() |
| 119 | editor := newTestEditor(t) |
| 120 | |
| 121 | for name, want := range map[string]syntax.Language{ |
| 122 | "README.md": syntax.LanguageMarkdown, |
| 123 | "compose.yaml": syntax.LanguageYAML, |
| 124 | "Dockerfile": syntax.LanguageDockerfile, |
| 125 | "index.html": syntax.LanguageHTML, |
| 126 | } { |
| 127 | path := filepath.Join(root, name) |
| 128 | writeFile(t, path, "# heading\n") |
| 129 | editor.Open(path) |
| 130 | |
| 131 | if got := editor.ActiveView().Language(); got != want { |
| 132 | t.Errorf("%s is coloured as %q, want %q", name, got, want) |
| 133 | } |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | func TestTheToolchainMenuIsCalledJavaScriptAndNoTwoMenusShareAHotKey(t *testing.T) { |
| 138 | // The bar answers the first menu whose hot key matches, so a clash makes |
| 139 | // one of the two unreachable from the keyboard — silently, and with every |
| 140 | // other test still passing. JavaScript takes J because none of the fixed |
| 141 | // menus does, which is exactly the sort of thing only this test notices. |
| 142 | editor := newTestEditor(t) |
| 143 | |
| 144 | seen := map[rune]string{} |
| 145 | found := false |
| 146 | for _, menu := range editor.MenuBar().Menus() { |
| 147 | label, hot, _ := ui.SplitHotKey(menu.Label) |
| 148 | if label == "JavaScript" { |
| 149 | found = true |
| 150 | } |
| 151 | if hot == 0 { |
| 152 | t.Errorf("the %q menu has no hot key", label) |
| 153 | continue |
| 154 | } |
| 155 | if other, clash := seen[hot]; clash { |
| 156 | t.Errorf("%q and %q both answer to Alt-%c", other, label, hot) |
| 157 | } |
| 158 | seen[hot] = label |
| 159 | } |
| 160 | if !found { |
| 161 | t.Error("there is no JavaScript menu on the bar") |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | func TestTheAgentMenuOffersToCreateTheAgentsFileInAProjectWithoutOne(t *testing.T) { |
| 166 | // The agent windows are turbo-core's; what this editor contributes is the |
| 167 | // starter file, and the menu item that writes it has to be reachable in a |
| 168 | // project that has none — which is every project, at first. |
| 169 | t.Chdir(t.TempDir()) |
| 170 | editor := newTestEditor(t) |
| 171 | |
| 172 | var agentMenu *ui.Menu |
| 173 | for _, menu := range editor.MenuBar().Menus() { |
| 174 | if label, _, _ := ui.SplitHotKey(menu.Label); label == "Agent" { |
| 175 | agentMenu = menu |
| 176 | } |
| 177 | } |
| 178 | if agentMenu == nil { |
| 179 | t.Fatal("there is no Agent menu on the bar") |
| 180 | } |
| 181 | |
| 182 | for _, item := range agentMenu.Items { |
| 183 | if label, _, _ := ui.SplitHotKey(item.Label); label == "Create agents file" { |
| 184 | if item.Enabled != nil && !item.Enabled() { |
| 185 | t.Error("Create agents file is greyed out in a project that has no agents file") |
| 186 | } |
| 187 | return |
| 188 | } |
| 189 | } |
| 190 | t.Error("the Agent menu has no Create agents file item") |
| 191 | } |
| 192 | |
| 193 | // --- driven against a real typescript-language-server ----------------------- |
| 194 | |
| 195 | // TestCompletionEndToEndWithRealServer drives the exact sequence the command |
| 196 | // does at start-up: open the file first, start the language server second, |
| 197 | // then ask for a completion. |
| 198 | // |
| 199 | // That order is the whole point, and it is the one Turbo Go got wrong once: an |
| 200 | // editor that announces its open documents to a server which does not exist yet |
| 201 | // and never mentions them again gets answers about a file the server has never |
| 202 | // heard of — which looks, from the outside, exactly like completion not |
| 203 | // working. |
| 204 | // |
| 205 | // It skips itself when typescript-language-server is not installed, and under |
| 206 | // -short. |
| 207 | func TestCompletionEndToEndWithRealServer(t *testing.T) { |
| 208 | root, editor := startRealServer(t) |
| 209 | path := filepath.Join(root, "main.js") |
| 210 | |
| 211 | // Both lines on disk are blank. A function is *declared* by typing, at top |
| 212 | // level, then its name is typed inside main, so the answer can only come |
| 213 | // from what the editor told the server — which is the whole point of this |
| 214 | // test. The server offers every global for any file at all, so a |
| 215 | // completion holding console would prove nothing; a completion holding a |
| 216 | // function that exists only in the buffer proves the buffer was sent. |
| 217 | view := editor.ActiveView() |
| 218 | view.Buffer().SetCursor(buffer.Position{Line: declarationLine, Col: 0}) |
| 219 | typeText(editor, "function zorglub(x) { return x; }") |
| 220 | view.Buffer().SetCursor(buffer.Position{Line: completionLine, Col: 2}) |
| 221 | typeText(editor, "zorg") |
| 222 | |
| 223 | if !waitForCompletion(t, editor) { |
| 224 | t.Fatalf("no completion list opened for %s; the status bar says %q", path, editor.StatusBar().Message()) |
| 225 | } |
| 226 | if !completionOffers(editor, "zorglub") { |
| 227 | t.Errorf("the list does not offer the function typed into the buffer; it has %d entries", editor.Completion().Count()) |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | func TestGoToDefinitionWithRealServer(t *testing.T) { |
| 232 | root, editor := startRealServer(t) |
| 233 | path := filepath.Join(root, "main.js") |
| 234 | |
| 235 | locations := waitForLocations(t, func(ctx context.Context) ([]lsp.Location, error) { |
| 236 | return editor.Language().Definition(ctx, path, callLine, callColumn, callLineText) |
| 237 | }) |
| 238 | |
| 239 | if len(locations) != 1 { |
| 240 | t.Fatalf("the call to helper has %d definitions, want exactly 1: %v", len(locations), locations) |
| 241 | } |
| 242 | if got := locations[0].Range.Start.Line; got != helperLine { |
| 243 | t.Errorf("the definition of helper is on line %d, want %d", got, helperLine) |
| 244 | } |
| 245 | } |
| 246 | |
| 247 | func TestReferencesListEveryUseWithRealServer(t *testing.T) { |
| 248 | // helper is declared once and called twice. Three answers is the shape |
| 249 | // the editor turns into a list rather than a jump, and the one a single |
| 250 | // definition never exercises. |
| 251 | root, editor := startRealServer(t) |
| 252 | path := filepath.Join(root, "main.js") |
| 253 | |
| 254 | locations := waitForLocations(t, func(ctx context.Context) ([]lsp.Location, error) { |
| 255 | return editor.Language().References(ctx, path, helperLine, helperColumn, helperLineText) |
| 256 | }) |
| 257 | |
| 258 | lines := map[int]bool{} |
| 259 | for _, location := range locations { |
| 260 | lines[location.Range.Start.Line] = true |
| 261 | } |
| 262 | for _, want := range []int{firstCallLine, secondCallLine} { |
| 263 | if !lines[want] { |
| 264 | t.Errorf("the references to helper do not include the call on line %d: %v", want, lines) |
| 265 | } |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | func TestImplementationsOfAClassAreItsSubclassesWithRealServer(t *testing.T) { |
| 270 | // Shape has two subclasses. Asking for its implementations is the one |
| 271 | // question whose answer is a list by nature, and the server answers it |
| 272 | // for JavaScript as it does for TypeScript. |
| 273 | root, editor := startRealServer(t) |
| 274 | path := filepath.Join(root, "main.js") |
| 275 | |
| 276 | locations := waitForLocations(t, func(ctx context.Context) ([]lsp.Location, error) { |
| 277 | return editor.Language().Implementation(ctx, path, shapeLine, shapeColumn, shapeLineText) |
| 278 | }) |
| 279 | |
| 280 | lines := map[int]bool{} |
| 281 | for _, location := range locations { |
| 282 | lines[location.Range.Start.Line] = true |
| 283 | } |
| 284 | for _, want := range []int{circleLine, squareLine} { |
| 285 | if !lines[want] { |
| 286 | t.Errorf("the implementations of Shape do not include the class on line %d: %v", want, lines) |
| 287 | } |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | func TestTypeDefinitionOfAnInstanceIsItsClassWithRealServer(t *testing.T) { |
| 292 | root, editor := startRealServer(t) |
| 293 | path := filepath.Join(root, "main.js") |
| 294 | |
| 295 | locations := waitForLocations(t, func(ctx context.Context) ([]lsp.Location, error) { |
| 296 | return editor.Language().TypeDefinition(ctx, path, shapeVarLine, shapeVarColumn, shapeVarLineText) |
| 297 | }) |
| 298 | |
| 299 | if len(locations) != 1 || locations[0].Range.Start.Line != circleLine { |
| 300 | t.Errorf("the type of `shape` is defined at %v, want line %d (class Circle)", locations, circleLine) |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | func TestHoverShowsTheDocCommentAboveADeclarationWithRealServer(t *testing.T) { |
| 305 | // A JSDoc block above a function is its documentation, and the server |
| 306 | // shows it on hover. This is what F1 — Code ▸ Describe symbol — draws, and |
| 307 | // it is the one answer here that carries prose a person wrote. |
| 308 | root, editor := startRealServer(t) |
| 309 | path := filepath.Join(root, "main.js") |
| 310 | |
| 311 | var text string |
| 312 | waitUntil(t, 30*time.Second, func() bool { |
| 313 | ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) |
| 314 | defer cancel() |
| 315 | answer, err := editor.Language().Hover(ctx, path, callLine, callColumn, callLineText) |
| 316 | if err != nil { |
| 317 | return false |
| 318 | } |
| 319 | text = answer |
| 320 | return strings.Contains(text, "Adds one") |
| 321 | }) |
| 322 | |
| 323 | if !strings.Contains(text, "Adds one") { |
| 324 | t.Errorf("hovering helper gave %q, want the comment written above its declaration", text) |
| 325 | } |
| 326 | } |
| 327 | |
| 328 | func TestTheSymbolsOfAFileWithRealServer(t *testing.T) { |
| 329 | root, editor := startRealServer(t) |
| 330 | path := filepath.Join(root, "main.js") |
| 331 | |
| 332 | var symbols []lsp.Symbol |
| 333 | waitUntil(t, 30*time.Second, func() bool { |
| 334 | ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) |
| 335 | defer cancel() |
| 336 | found, err := editor.Language().DocumentSymbols(ctx, path) |
| 337 | if err != nil { |
| 338 | return false |
| 339 | } |
| 340 | symbols = found |
| 341 | return len(symbols) > 0 |
| 342 | }) |
| 343 | |
| 344 | names := map[string]bool{} |
| 345 | for _, symbol := range symbols { |
| 346 | names[symbol.Name] = true |
| 347 | } |
| 348 | for _, want := range []string{"helper", "first", "second", "Shape", "Circle", "Square", "main"} { |
| 349 | if !names[want] { |
| 350 | t.Errorf("the file's symbols do not include %q: %v", want, names) |
| 351 | } |
| 352 | } |
| 353 | } |
| 354 | |
| 355 | func TestAProjectWideSymbolSearchWithRealServer(t *testing.T) { |
| 356 | // Ctrl-T. The server indexes the project it was started in, so the |
| 357 | // answer names the file the symbol is declared in. |
| 358 | _, editor := startRealServer(t) |
| 359 | |
| 360 | var symbols []lsp.Symbol |
| 361 | waitUntil(t, 30*time.Second, func() bool { |
| 362 | ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) |
| 363 | defer cancel() |
| 364 | found, err := editor.Language().WorkspaceSymbols(ctx, "helper") |
| 365 | if err != nil { |
| 366 | return false |
| 367 | } |
| 368 | symbols = found |
| 369 | return len(symbols) > 0 |
| 370 | }) |
| 371 | |
| 372 | found := false |
| 373 | for _, symbol := range symbols { |
| 374 | if symbol.Name == "helper" { |
| 375 | found = true |
| 376 | } |
| 377 | } |
| 378 | if !found { |
| 379 | t.Errorf("a project-wide search for helper found %v", symbols) |
| 380 | } |
| 381 | } |
| 382 | |
| 383 | // Diagnostics are the one thing a language server sends without being asked, |
| 384 | // and the only feature whose failure looks exactly like success: an editor with |
| 385 | // no error to show and one that cannot find the error are the same blank |
| 386 | // gutter. So this opens a file that does not parse and waits for the mark. |
| 387 | func TestDiagnosticsForAFileThatDoesNotParseWithRealServer(t *testing.T) { |
| 388 | root, editor := startRealServerOn(t, brokenScript) |
| 389 | path := filepath.Join(root, "main.js") |
| 390 | |
| 391 | waitUntil(t, 30*time.Second, func() bool { |
| 392 | editor.Tick() |
| 393 | return len(editor.Language().Diagnostics(path)) > 0 |
| 394 | }) |
| 395 | |
| 396 | problems := editor.Language().Diagnostics(path) |
| 397 | if len(problems) == 0 { |
| 398 | t.Fatalf("no diagnostic ever arrived for %s; the status bar says %q", path, editor.StatusBar().Message()) |
| 399 | } |
| 400 | if _, ok := editor.Language().FirstError(path); !ok { |
| 401 | t.Errorf("the diagnostics hold no error, only %v", problems) |
| 402 | } |
| 403 | } |
| 404 | |
| 405 | // --- the fixtures and the waiting ------------------------------------------- |
| 406 | |
| 407 | // realScript is the file every language-server test works against. Line |
| 408 | // numbers are counted from zero and are named by the constants below, so |
| 409 | // inserting a line here moves them and the constants have to move too. |
| 410 | // |
| 411 | // It runs under node with no error, which matters: a fixture the runtime |
| 412 | // complains about would make the diagnostics test pass for the wrong reason. |
| 413 | const realScript = "// A tiny module.\n" + // 0 |
| 414 | "\n" + // 1 |
| 415 | "/** Adds one. */\n" + // 2 |
| 416 | "function helper(x) {\n" + // 3 |
| 417 | " return x + 1;\n" + // 4 |
| 418 | "}\n" + // 5 |
| 419 | "\n" + // 6 |
| 420 | "function first(x) {\n" + // 7 |
| 421 | " return helper(x);\n" + // 8 |
| 422 | "}\n" + // 9 |
| 423 | "\n" + // 10 |
| 424 | "function second(x) {\n" + // 11 |
| 425 | " return helper(x) * 2;\n" + // 12 |
| 426 | "}\n" + // 13 |
| 427 | "\n" + // 14 |
| 428 | "class Shape {\n" + // 15 |
| 429 | " area() {\n" + // 16 |
| 430 | " return 0;\n" + // 17 |
| 431 | " }\n" + // 18 |
| 432 | "}\n" + // 19 |
| 433 | "\n" + // 20 |
| 434 | "class Circle extends Shape {\n" + // 21 |
| 435 | " area() {\n" + // 22 |
| 436 | " return 3;\n" + // 23 |
| 437 | " }\n" + // 24 |
| 438 | "}\n" + // 25 |
| 439 | "\n" + // 26 |
| 440 | "class Square extends Shape {\n" + // 27 |
| 441 | " area() {\n" + // 28 |
| 442 | " return 4;\n" + // 29 |
| 443 | " }\n" + // 30 |
| 444 | "}\n" + // 31 |
| 445 | "\n" + // 32 |
| 446 | "\n" + // 33 ← where the declaration is typed, at top level |
| 447 | "\n" + // 34 |
| 448 | "function main() {\n" + // 35 |
| 449 | " const text = \"hi\";\n" + // 36 |
| 450 | " const shape = new Circle();\n" + // 37 |
| 451 | " \n" + // 38 ← two spaces, and where the completion is typed |
| 452 | " console.log(first(1) + second(2) + text, shape.area());\n" + // 39 |
| 453 | "}\n" + // 40 |
| 454 | "\n" + // 41 |
| 455 | "main();\n" // 42 |
| 456 | |
| 457 | // brokenScript is a file that does not parse: the parenthesis of main's |
| 458 | // parameter list is never closed. It exists as a second fixture rather than as |
| 459 | // a line added to the first, because a file holding a syntax error is a file |
| 460 | // whose *other* answers are worth nothing: the completion test would then be |
| 461 | // measuring a parse that never finished. |
| 462 | const brokenScript = "// Does not parse.\n" + |
| 463 | "\n" + |
| 464 | "function main( {\n" + |
| 465 | " const x = (1 +\n" |
| 466 | |
| 467 | // Where the fixture's interesting lines are, counted from zero. |
| 468 | const ( |
| 469 | helperLine = 3 |
| 470 | helperColumn = 9 |
| 471 | helperLineText = "function helper(x) {" |
| 472 | callLine = 8 |
| 473 | callColumn = 9 |
| 474 | callLineText = " return helper(x);" |
| 475 | firstCallLine = 8 |
| 476 | secondCallLine = 12 |
| 477 | shapeLine = 15 |
| 478 | shapeColumn = 6 |
| 479 | shapeLineText = "class Shape {" |
| 480 | circleLine = 21 |
| 481 | squareLine = 27 |
| 482 | declarationLine = 33 |
| 483 | shapeVarLine = 37 |
| 484 | shapeVarColumn = 8 |
| 485 | shapeVarLineText = " const shape = new Circle();" |
| 486 | completionLine = 38 |
| 487 | ) |
| 488 | |
| 489 | // startRealServer writes a project, opens its file, starts the server and |
| 490 | // waits for it, in the order the command does. It skips the test when the |
| 491 | // server is missing. |
| 492 | func startRealServer(t *testing.T) (root string, editor *app.App) { |
| 493 | t.Helper() |
| 494 | return startRealServerOn(t, realScript) |
| 495 | } |
| 496 | |
| 497 | // startRealServerOn is startRealServer over a chosen main.js. |
| 498 | func startRealServerOn(t *testing.T, source string) (root string, editor *app.App) { |
| 499 | t.Helper() |
| 500 | if testing.Short() { |
| 501 | t.Skip("-short: not starting a language server") |
| 502 | } |
| 503 | |
| 504 | server, err := lsp.FindServer(jslang.Profile().Server) |
| 505 | if errors.Is(err, lsp.ErrServerNotFound) { |
| 506 | t.Skipf("%s is not installed; %s", jslang.ServerCommand, jslang.InstallHint) |
| 507 | } |
| 508 | // Finding it is not the same as being able to run it: a shim left behind |
| 509 | // by a version manager whose Node has since been removed is on PATH and |
| 510 | // fails only when started. |
| 511 | if !serverRuns(server) { |
| 512 | t.Skipf("%s at %s cannot run; %s", jslang.ServerCommand, server, jslang.InstallHint) |
| 513 | } |
| 514 | |
| 515 | root = t.TempDir() |
| 516 | writeFile(t, filepath.Join(root, "package.json"), "{\n \"name\": \"demo\",\n \"type\": \"commonjs\"\n}\n") |
| 517 | writeFile(t, filepath.Join(root, "main.js"), source) |
| 518 | |
| 519 | editor = newTestEditor(t) |
| 520 | |
| 521 | // 1. Open the file, exactly as main does — before there is any server. |
| 522 | editor.Open(filepath.Join(root, "main.js")) |
| 523 | |
| 524 | // 2. Start the language server, exactly as main does — afterwards, in the |
| 525 | // directory holding package.json, which is what ProjectRoot answers. |
| 526 | ctx, cancel := context.WithCancel(t.Context()) |
| 527 | t.Cleanup(cancel) |
| 528 | editor.StartLanguageServer(ctx, root) |
| 529 | t.Cleanup(func() { editor.Language().Stop(context.Background()) }) |
| 530 | |
| 531 | waitUntilReady(t, editor) |
| 532 | |
| 533 | // 3. Let the event loop notice the server is ready, as Run does on every |
| 534 | // turn. This is what announces the file that was already open. |
| 535 | editor.Tick() |
| 536 | return root, editor |
| 537 | } |
| 538 | |
| 539 | // newTestEditor returns Turbo JS drawing on a simulated terminal, set up the |
| 540 | // way the command sets it up. |
| 541 | func newTestEditor(t *testing.T) *app.App { |
| 542 | t.Helper() |
| 543 | |
| 544 | jslang.Register() |
| 545 | screen := tcell.NewSimulationScreen("UTF-8") |
| 546 | if err := screen.Init(); err != nil { |
| 547 | t.Fatalf("initialising the simulation screen: %v", err) |
| 548 | } |
| 549 | t.Cleanup(screen.Fini) |
| 550 | screen.SetSize(80, 24) |
| 551 | |
| 552 | // Never read the themes or snippets of whoever is running the tests. |
| 553 | p := jslang.Profile() |
| 554 | t.Setenv(p.ThemeDirEnvVar(), t.TempDir()) |
| 555 | t.Setenv(p.SnippetDirEnvVar(), t.TempDir()) |
| 556 | |
| 557 | editor := app.New(screen, "turbo-classic", p) |
| 558 | editor.Render() |
| 559 | return editor |
| 560 | } |
| 561 | |
| 562 | // coloured reports whether a rune column on a line carries a class. |
| 563 | func coloured(spans []syntax.Span, col int, want syntax.Class) bool { |
| 564 | for _, span := range spans { |
| 565 | if span.Start <= col && col < span.End { |
| 566 | return span.Class == want |
| 567 | } |
| 568 | } |
| 569 | return false |
| 570 | } |
| 571 | |
| 572 | // typeText sends a run of printable characters through the whole routing chain. |
| 573 | func typeText(editor *app.App, text string) { |
| 574 | for _, r := range text { |
| 575 | editor.Handle(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) |
| 576 | } |
| 577 | } |
| 578 | |
| 579 | // completionOffers reports whether the open popup holds an entry starting with |
| 580 | // a label. |
| 581 | func completionOffers(editor *app.App, label string) bool { |
| 582 | for _, item := range editor.Completion().Matches() { |
| 583 | if strings.HasPrefix(item.Label, label) { |
| 584 | return true |
| 585 | } |
| 586 | } |
| 587 | return false |
| 588 | } |
| 589 | |
| 590 | // waitUntilReady blocks until the language server has finished starting. |
| 591 | func waitUntilReady(t *testing.T, editor *app.App) { |
| 592 | t.Helper() |
| 593 | |
| 594 | deadline := time.After(lsp.InitializeTimeout) |
| 595 | for !editor.Language().Ready() { |
| 596 | select { |
| 597 | case <-deadline: |
| 598 | t.Fatalf("the language server never became ready: %s", editor.Language().Status()) |
| 599 | case <-time.After(10 * time.Millisecond): |
| 600 | } |
| 601 | } |
| 602 | } |
| 603 | |
| 604 | // waitUntil polls a condition until it holds or the time runs out, and fails |
| 605 | // the test if it never does. |
| 606 | func waitUntil(t *testing.T, within time.Duration, done func() bool) { |
| 607 | t.Helper() |
| 608 | |
| 609 | deadline := time.Now().Add(within) |
| 610 | for time.Now().Before(deadline) { |
| 611 | if done() { |
| 612 | return |
| 613 | } |
| 614 | time.Sleep(200 * time.Millisecond) |
| 615 | } |
| 616 | t.Errorf("the server never answered within %s", within) |
| 617 | } |
| 618 | |
| 619 | // waitForLocations asks a location question until it is answered, because a |
| 620 | // server that is still loading its project answers an empty list rather than |
| 621 | // an error. |
| 622 | func waitForLocations(t *testing.T, ask func(context.Context) ([]lsp.Location, error)) []lsp.Location { |
| 623 | t.Helper() |
| 624 | |
| 625 | var found []lsp.Location |
| 626 | waitUntil(t, 30*time.Second, func() bool { |
| 627 | ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) |
| 628 | defer cancel() |
| 629 | |
| 630 | locations, err := ask(ctx) |
| 631 | if err != nil { |
| 632 | return false |
| 633 | } |
| 634 | found = locations |
| 635 | return len(found) > 0 |
| 636 | }) |
| 637 | return found |
| 638 | } |
| 639 | |
| 640 | // waitForCompletion asks for a completion until one arrives, or gives up. |
| 641 | // |
| 642 | // tsserver loads its project after it has finished initialising, and answers |
| 643 | // an empty list until that is done. There is no notification this client reads |
| 644 | // that says when — so it asks again, which is what the editor's user would do. |
| 645 | func waitForCompletion(t *testing.T, editor *app.App) bool { |
| 646 | t.Helper() |
| 647 | |
| 648 | deadline := time.Now().Add(60 * time.Second) |
| 649 | for time.Now().Before(deadline) { |
| 650 | if editor.Completion().Visible() { |
| 651 | return true |
| 652 | } |
| 653 | editor.RequestCompletion() |
| 654 | if editor.Completion().Visible() { |
| 655 | return true |
| 656 | } |
| 657 | time.Sleep(500 * time.Millisecond) |
| 658 | } |
| 659 | return false |
| 660 | } |
| 661 | |
| 662 | // serverRuns reports whether the language server at path actually starts. |
| 663 | func serverRuns(path string) bool { |
| 664 | return exec.Command(path, "--version").Run() == nil |
| 665 | } |
| 666 | |
| 667 | // writeFile creates a file, making its directory first. |
| 668 | func writeFile(t *testing.T, path, content string) { |
| 669 | t.Helper() |
| 670 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 671 | t.Fatalf("creating %s: %v", filepath.Dir(path), err) |
| 672 | } |
| 673 | if err := os.WriteFile(path, []byte(content), 0o644); err != nil { |
| 674 | t.Fatalf("writing %s: %v", path, err) |
| 675 | } |
| 676 | } |