diff --git a/packages/vscode/__fixtures__/template-imports-app-ts-plugin/package.json b/packages/vscode/__fixtures__/template-imports-app-ts-plugin/package.json new file mode 100644 index 000000000..3c9745777 --- /dev/null +++ b/packages/vscode/__fixtures__/template-imports-app-ts-plugin/package.json @@ -0,0 +1,4 @@ +{ + "name": "template-imports-app-ts-plugin", + "private": true +} diff --git a/packages/vscode/__fixtures__/template-imports-app-ts-plugin/src/Greeting.gts b/packages/vscode/__fixtures__/template-imports-app-ts-plugin/src/Greeting.gts new file mode 100644 index 000000000..c919c8dac --- /dev/null +++ b/packages/vscode/__fixtures__/template-imports-app-ts-plugin/src/Greeting.gts @@ -0,0 +1,13 @@ +import Component from '@glimmer/component'; + +export interface GreetingSignature { + Args: { target: string }; +} + +export default class Greeting extends Component { + private message = 'Hello'; + + +} diff --git a/packages/vscode/__fixtures__/template-imports-app-ts-plugin/src/index.gts b/packages/vscode/__fixtures__/template-imports-app-ts-plugin/src/index.gts new file mode 100644 index 000000000..fae47f952 --- /dev/null +++ b/packages/vscode/__fixtures__/template-imports-app-ts-plugin/src/index.gts @@ -0,0 +1,12 @@ +import '@glint/environment-ember-loose'; +import '@glint/environment-ember-template-imports'; + +// TS-PLUGIN: I had to add the .gts extension in order for this to work, otherwise +// Cannot find module './Greeting' or its corresponding type declarations. [ts-plugin(2307)] + +// import Greeting from './Greeting'; +import Greeting from './Greeting.gts'; + + diff --git a/packages/vscode/__fixtures__/template-imports-app-ts-plugin/tsconfig.json b/packages/vscode/__fixtures__/template-imports-app-ts-plugin/tsconfig.json new file mode 100644 index 000000000..6811f1e23 --- /dev/null +++ b/packages/vscode/__fixtures__/template-imports-app-ts-plugin/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../../tsconfig.compileroptions.json", + "glint": { + "environment": ["ember-loose", "ember-template-imports"], + "enableTsPlugin": true + }, + "compilerOptions": { + "baseUrl": ".", + + // TODO: work out the interplay between this and typescriptServerPlugins in extension's package.json + "plugins": [{ "name": "@glint/typescript-plugin" }] + } +} diff --git a/packages/vscode/__tests__/smoketest-ember.test.ts b/packages/vscode/__tests__/language-server-tests/smoketest-ember.test.ts similarity index 98% rename from packages/vscode/__tests__/smoketest-ember.test.ts rename to packages/vscode/__tests__/language-server-tests/smoketest-ember.test.ts index e2b459d54..7e22c6080 100644 --- a/packages/vscode/__tests__/smoketest-ember.test.ts +++ b/packages/vscode/__tests__/language-server-tests/smoketest-ember.test.ts @@ -14,10 +14,10 @@ import { import * as path from 'path'; import { describe, afterEach, test } from 'mocha'; import { expect } from 'expect'; -import { waitUntil } from './helpers/async'; +import { waitUntil } from '../helpers/async'; describe.skip('Smoke test: Ember', () => { - const rootDir = path.resolve(__dirname, '../../__fixtures__/ember-app'); + const rootDir = path.resolve(__dirname, '../../../__fixtures__/ember-app'); afterEach(async () => { while (window.activeTextEditor) { diff --git a/packages/vscode/__tests__/smoketest-template-imports.test.ts b/packages/vscode/__tests__/language-server-tests/smoketest-template-imports.test.ts similarity index 96% rename from packages/vscode/__tests__/smoketest-template-imports.test.ts rename to packages/vscode/__tests__/language-server-tests/smoketest-template-imports.test.ts index 4ba8d2abf..5e66f413f 100644 --- a/packages/vscode/__tests__/smoketest-template-imports.test.ts +++ b/packages/vscode/__tests__/language-server-tests/smoketest-template-imports.test.ts @@ -12,10 +12,10 @@ import { import * as path from 'path'; import { describe, afterEach, test } from 'mocha'; import { expect } from 'expect'; -import { waitUntil } from './helpers/async'; +import { waitUntil } from '../helpers/async'; describe('Smoke test: ETI Environment', () => { - const rootDir = path.resolve(__dirname, '../../__fixtures__/template-imports-app'); + const rootDir = path.resolve(__dirname, '../../../__fixtures__/template-imports-app'); afterEach(async () => { while (window.activeTextEditor) { diff --git a/packages/vscode/__tests__/support/launch-from-cli.mts b/packages/vscode/__tests__/support/launch-from-cli.mts index be7014ad7..c4069e6b8 100644 --- a/packages/vscode/__tests__/support/launch-from-cli.mts +++ b/packages/vscode/__tests__/support/launch-from-cli.mts @@ -2,30 +2,66 @@ import * as path from 'node:path'; import * as os from 'node:os'; import { fileURLToPath } from 'node:url'; import { runTests } from '@vscode/test-electron'; +import * as fs from 'node:fs'; const dirname = path.dirname(fileURLToPath(import.meta.url)); const packageRoot = path.resolve(dirname, '../../..'); -const emptyTempDir = path.join(os.tmpdir(), `user-data-${Math.random()}`); +const emptyExtensionsDir = path.join(os.tmpdir(), `extensions-${Math.random()}`); +const emptyUserDataDir = path.join(os.tmpdir(), `user-data-${Math.random()}`); + +const settingsDir = path.join(emptyUserDataDir, 'User'); +fs.mkdirSync(settingsDir, { recursive: true }); + +const userPreferences = { + // When testing TS Plugin, it can be useful to look at tsserver logs within + // the test runner VSCode instance. To do this, uncomment the following line, + // and then check the logs for TypeScript + // "typescript.tsserver.log": "verbose", +}; + +fs.writeFileSync(path.join(settingsDir, 'settings.json'), JSON.stringify(userPreferences, null, 2)); + +const testType = process.argv[2]; + +let disableExtensionArgs: string[] = []; + +let testRunner: string; +switch (testType) { + case 'language-server': + testRunner = 'vscode-runner-language-server.js'; + + // Disable vanilla TS for full "takeover" mode. + disableExtensionArgs = ['--disable-extension', 'vscode.typescript-language-features']; + break; + case 'ts-plugin': + testRunner = 'vscode-runner-ts-plugin.js'; + + // Note: here, we WANT vanilla TS to be enabled since we're testing the TS Plugin. + break; + default: + console.error('Test type must be either "language-server" or "ts-plugin"'); + process.exit(1); +} try { await runTests({ extensionDevelopmentPath: packageRoot, - extensionTestsPath: path.resolve(dirname, 'vscode-runner.js'), + extensionTestsPath: path.resolve(dirname, testRunner), launchArgs: [ // Don't show the "hey do you trust this folder?" prompt '--disable-workspace-trust', - // Explicitly turn off the built-in TS extension - '--disable-extension', - 'vscode.typescript-language-features', + ...disableExtensionArgs, // Point at an empty directory so no third-party extensions load '--extensions-dir', - emptyTempDir, + emptyExtensionsDir, // Point at an empty directory so we don't have to contend with any local user preferences '--user-data-dir', - emptyTempDir, - // Load the app fixtures + emptyUserDataDir, + // Load the app fixtures. Note that it's ok to load fixtures that aren't used for the + // particular test type. `${packageRoot}/__fixtures__/ember-app`, `${packageRoot}/__fixtures__/template-imports-app`, + `${packageRoot}/__fixtures__/template-imports-app-ts-plugin`, ], }); } catch (error) { diff --git a/packages/vscode/__tests__/support/vscode-runner-language-server.ts b/packages/vscode/__tests__/support/vscode-runner-language-server.ts new file mode 100644 index 000000000..d955b4fb7 --- /dev/null +++ b/packages/vscode/__tests__/support/vscode-runner-language-server.ts @@ -0,0 +1,8 @@ +// This file is invoked by VSCode itself when configured to run extension +// tests via the `--extensionTestsPath` flag. + +import { run as runShared } from './vscode-runner'; + +export function run(runner: unknown, callback: (error: unknown, failures?: number) => void): void { + runShared(runner, callback, 'language-server-tests'); +} diff --git a/packages/vscode/__tests__/support/vscode-runner-ts-plugin.ts b/packages/vscode/__tests__/support/vscode-runner-ts-plugin.ts new file mode 100644 index 000000000..d0744b1de --- /dev/null +++ b/packages/vscode/__tests__/support/vscode-runner-ts-plugin.ts @@ -0,0 +1,8 @@ +// This file is invoked by VSCode itself when configured to run extension +// tests via the `--extensionTestsPath` flag. + +import { run as runShared } from './vscode-runner'; + +export function run(runner: unknown, callback: (error: unknown, failures?: number) => void): void { + runShared(runner, callback, 'ts-plugin-tests'); +} diff --git a/packages/vscode/__tests__/support/vscode-runner.ts b/packages/vscode/__tests__/support/vscode-runner.ts index 1d6cc6417..778699395 100644 --- a/packages/vscode/__tests__/support/vscode-runner.ts +++ b/packages/vscode/__tests__/support/vscode-runner.ts @@ -5,12 +5,16 @@ import * as path from 'node:path'; import * as glob from 'glob'; import Mocha = require('mocha'); -export function run(runner: unknown, callback: (error: unknown, failures?: number) => void): void { +export function run( + runner: unknown, + callback: (error: unknown, failures?: number) => void, + testSubfolder: 'language-server-tests' | 'ts-plugin-tests', +): void { try { let mocha = new Mocha({ color: true, slow: 3_000, timeout: 30_000 }); let tests = path.resolve(__dirname, '..').replace(/\\/g, '/'); - for (let testFile of glob.sync(`${tests}/**/*.test.js`)) { + for (let testFile of glob.sync(`${tests}/${testSubfolder}/**/*.test.js`)) { if (process.platform === 'win32') { // Mocha is weird about drive letter casing under Windows testFile = testFile[0].toLowerCase() + testFile.slice(1); diff --git a/packages/vscode/__tests__/ts-plugin-tests/smoketest-template-imports-ts-plugin.test.ts b/packages/vscode/__tests__/ts-plugin-tests/smoketest-template-imports-ts-plugin.test.ts new file mode 100644 index 000000000..ea06b93e2 --- /dev/null +++ b/packages/vscode/__tests__/ts-plugin-tests/smoketest-template-imports-ts-plugin.test.ts @@ -0,0 +1,154 @@ +import { + commands, + languages, + ViewColumn, + window, + Uri, + Range, + Position, + CodeAction, + workspace, +} from 'vscode'; +import * as path from 'path'; +import { describe, afterEach, test } from 'mocha'; +import { expect } from 'expect'; +import { waitUntil } from '../helpers/async'; + +describe('Smoke test: ETI Environment (TS Plugin Mode)', () => { + const rootDir = path.resolve(__dirname, '../../../__fixtures__/template-imports-app-ts-plugin'); + + afterEach(async () => { + while (window.activeTextEditor) { + await commands.executeCommand('workbench.action.files.revert'); + await commands.executeCommand('workbench.action.closeActiveEditor'); + } + }); + + describe('diagnostics for errors', () => { + // TODO: fix remaining tests and remove this `.only` + test.only('with a custom extension', async () => { + let scriptURI = Uri.file(`${rootDir}/src/index.gts`); + let scriptEditor = await window.showTextDocument(scriptURI, { viewColumn: ViewColumn.One }); + + // Ensure we have a clean bill of health + expect(languages.getDiagnostics(scriptURI)).toEqual([]); + + await hackishlyWaitForTypescriptPluginToActivate(); + + // Replace a string with a number + await scriptEditor.edit((edit) => { + edit.replace(new Range(10, 20, 10, 27), '{{123}}'); + + // Original range, in case we revert some of the TS-Plugin-specific + // edit.replace(new Range(6, 20, 6, 27), '{{123}}'); + }); + + // Wait for the diagnostic to show up + await waitUntil(() => languages.getDiagnostics(scriptURI).length); + + // Verify it's what we expect + expect(languages.getDiagnostics(scriptURI)).toMatchObject([ + { + message: "Type 'number' is not assignable to type 'string'.", + source: 'ts-plugin', + code: 2322, + // range: new Range(6, 13, 6, 19), + range: new Range(10, 13, 10, 19), + }, + ]); + }); + + describe('codeactions args', () => { + test('adds missing args from template into Args type', async () => { + let scriptURI = Uri.file(`${rootDir}/src/Greeting.gts`); + + // Open the script and the template + let scriptEditor = await window.showTextDocument(scriptURI, { viewColumn: ViewColumn.One }); + + // Ensure neither has any diagnostic messages + expect(languages.getDiagnostics(scriptURI)).toEqual([]); + + // Comment out a property in the script that's referenced in the template + await scriptEditor.edit((edit) => { + edit.insert(new Position(10, 4), '{{@undocumentedProperty}} '); + }); + + // Wait for a diagnostic to appear in the template + await waitUntil(() => languages.getDiagnostics(scriptURI).length); + + const fixes = await commands.executeCommand( + 'vscode.executeCodeActionProvider', + scriptURI, + new Range(new Position(10, 9), new Position(10, 9)), + ); + + expect(fixes.length).toBe(4); + + const fix = fixes.find((fix) => fix.title === "Declare property 'undocumentedProperty'"); + + expect(fix).toBeDefined(); + + // apply the missing arg fix + await workspace.applyEdit(fix!.edit!); + + await waitUntil( + () => + scriptEditor.document.getText().includes('undocumentedProperty: any') && + languages.getDiagnostics(scriptURI).length === 0, + ); + }); + }); + + describe('codeactions locals', () => { + test('add local props to a class', async () => { + let scriptURI = Uri.file(`${rootDir}/src/Greeting.gts`); + + // Open the script and the template + let scriptEditor = await window.showTextDocument(scriptURI, { viewColumn: ViewColumn.One }); + + // Ensure neither has any diagnostic messages + expect(languages.getDiagnostics(scriptURI)).toEqual([]); + + await scriptEditor.edit((edit) => { + edit.insert(new Position(10, 4), '{{this.localProp}} '); + }); + + // Wait for a diagnostic to appear in the template + await waitUntil(() => languages.getDiagnostics(scriptURI).length); + + const fixes = await commands.executeCommand( + 'vscode.executeCodeActionProvider', + scriptURI, + new Range(new Position(10, 12), new Position(10, 12)), + ); + + expect(fixes.length).toBe(4); + + const fix = fixes.find((fix) => fix.title === "Declare property 'localProp'"); + + expect(fix).toBeDefined(); + + // select ignore + await workspace.applyEdit(fix!.edit!); + + await waitUntil( + () => + scriptEditor.document.getText().includes('localProp: any') && + languages.getDiagnostics(scriptURI).length, + ); + }); + }); + }); +}); + +/** + * We shouldn't have to use this function for many reasons: + * + * 1. Using timers to avoid a race condition is brittle + * 2. More importantly: this only solves the problem of "make sure the TS Plugin is activated + * before we edit the file" when what we REALLY want is diagnostics to kick in without + * editing. + */ +function hackishlyWaitForTypescriptPluginToActivate(): Promise { + return new Promise((resolve) => setTimeout(resolve, 5000)); +} diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 69abe0038..37761b60c 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -25,7 +25,9 @@ ], "scripts": { "pretest": "yarn build", - "test": "node lib/__tests__/support/launch-from-cli.mjs", + "test": "yarn test-language-server && yarn test-ts-plugin", + "test-language-server": "node lib/__tests__/support/launch-from-cli.mjs language-server", + "test-ts-plugin": "node lib/__tests__/support/launch-from-cli.mjs ts-plugin", "test:typecheck": "echo 'no standalone typecheck within this project'", "test:tsc": "echo 'no standalone tsc within this project'", "build": "yarn compile && yarn bundle",