Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,9 @@ jobs:
- run: pnpm install --frozen-lockfile

- run: npm run test

- run: xvfb-run -a npm run test:vscode
if: runner.os == 'Linux'

- run: npm run test:vscode
if: runner.os != 'Linux'
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ dist
node_modules
*.tsbuildinfo
*.vsix
.vscode-test

extensions/vscode/out
extensions/vscode/e2e/out
extensions/vscode/tests/embeddedGrammars/*.tmLanguage.json
packages/*/*.d.ts
packages/*/*.js
Expand Down
17 changes: 17 additions & 0 deletions extensions/vscode/e2e/.vscode-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
const path = require('node:path');
const { defineConfig } = require('@vscode/test-cli');

module.exports = defineConfig({
extensionDevelopmentPath: path.join(__dirname, '../'),
workspaceFolder: path.join(__dirname, './workspace'),

// Use a dedicated out dir for test JS files
files: ['out/**/*.e2e-test.js'],

// Mocha options
mocha: {
ui: 'tdd',
timeout: 0,
color: true,
},
});
160 changes: 160 additions & 0 deletions extensions/vscode/e2e/suite/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import * as assert from 'node:assert';
import * as path from 'node:path';
import * as vscode from 'vscode';

// Singleton document to be used across tests
let openDoc: vscode.TextDocument;

const workspacePath = path.join(__dirname, '../workspace');

/**
* Opens a document in the test workspace.
* Sets the shared `openDoc` variable to the opened document.
* Waits until the TypeScript server provides the hover information.
*/
export async function openDocument(fileName: string): Promise<void> {
const uri = vscode.Uri.file(path.join(workspacePath, fileName));

const doc = await vscode.workspace.openTextDocument(uri);

await vscode.window.showTextDocument(doc);

openDoc = doc;
}

/**
* Ensures the TypeScript language server is fully initialized and ready to provide rich type information.
*
* @remarks
* This method of waiting for server readiness by inspecting hover content is a heuristic.
* More robust or direct methods for determining server readiness should be explored
* for better test stability and reliability.
*/
export async function ensureTypeScriptServerReady(fileName: string, keyword: string): Promise<void> {
await openDocument(fileName);

console.log('Waiting for TypeScript server to be ready...');

if (!openDoc) {
throw new Error(`Document ${fileName} was not opened successfully.`);
}

const position = openDoc.positionAt(openDoc.getText().indexOf(keyword));

let attempt = 0;
const maxAttempts = 60; // Approx 30 seconds if each attempt is 500ms
const retryDelay = 500; // ms

while (attempt < maxAttempts) {
attempt++;
const hovers = await vscode.commands.executeCommand<vscode.Hover[]>(
'vscode.executeHoverProvider',
openDoc.uri,
position,
);

// We are interested in the first hover provider, which is TypeScript's native hover.
const content = hovers[0]?.contents[0];
if (!content) {
await new Promise(resolve => setTimeout(resolve, retryDelay));
continue;
}

const hover = typeof content === 'string' ? content : content.value;

// Check for specific content indicating the server is ready and AST is parsed
// "a?: string" is part of the ServerReadinessProbe type definition in canary.ts
if (!hover.includes('loading') && hover.includes('a?: string')) {
console.log(`TypeScript server is ready after ${attempt} attempts.`);
return; // Server is ready
}

if (attempt % 10 === 0) {
// Log progress occasionally
console.log(`Still waiting for TS server... Attempt ${attempt}.`);
}
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
throw new Error(`TypeScript server did not become ready after ${maxAttempts} attempts.`);
}

/**
* Retrieves the hover information for a given keyword in the currently opened document.
* @returns {Promise<string>} The prettified type string from the hover content with whitespace normalized.
*/
export async function getHover(
getPosition: (doc: vscode.TextDocument) => vscode.Position,
): Promise<string[]> {
const position = getPosition(openDoc);

const hovers = await vscode.commands.executeCommand<vscode.Hover[]>(
'vscode.executeHoverProvider',
openDoc.uri,
position,
);

assert.ok(hovers, 'Expected hover results to be defined');
assert.ok(hovers.length > 0, 'Expected at least one hover result');

return hovers
.map(hover => hover.contents[0])
.filter(content => content !== undefined)
.map(content => (typeof content === 'string' ? content : content.value))
.map(normalizeTypeString); // Normalize each hover content type string
}

/**
* Cleans up a TypeScript type string by removing specific Markdown fences and normalizing whitespace.
* This function is used to ensure that the type string is in a clean format for comparison.
*/
function normalizeTypeString(input: string): string {
let type = input.trim();

// Remove the specific TypeScript Markdown fences
const leadingFence = '```typescript\n';
const trailingFence = '\n```';

if (type.startsWith(leadingFence)) {
type = type.substring(leadingFence.length);
}

if (type.endsWith(trailingFence)) {
type = type.substring(0, type.length - trailingFence.length);
}

type = type
.replace(/\s+/g, ' ') // Collapse all whitespace (including newlines/tabs) to single spaces
.trim(); // Remove leading/trailing spaces

// Remove a single trailing semicolon, if present
if (type.endsWith(';')) {
type = type.slice(0, -1).trim();
}

return type;
}

/**
* Asserts that actual hover contents match the expected content
* after normalization (e.g. whitespace and Markdown fences removed).
*/
export function assertHover(hovers: string[], expected: string): void {
const normalizedExpected = normalizeTypeString(expected);
assert.ok(
hovers.includes(normalizedExpected),
`Expected hover content to be "${expected}", but got "${hovers.join(', ')}"`,
);
}

/**
* Returns the index of the nth occurrence of a pattern in a string.
* @returns The index of the nth match, or -1 if not found.
*/
export function nthIndex(str: string, pattern: string, n: number): number {
let index = -1;
for (let i = 0; i < n; i++) {
index = str.indexOf(pattern, index + 1);
if (index === -1) break;
}
return index;
}
16 changes: 16 additions & 0 deletions extensions/vscode/e2e/suite/vue.e2e-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { assertHover, ensureTypeScriptServerReady, getHover, nthIndex } from './utils';

suite('Vue Hover Types', () => {
suiteSetup(async function() {
await ensureTypeScriptServerReady('test.vue', 'ServerReadinessProbe');
});

test('primitive', async () => {
const hover = await getHover(
doc => doc.positionAt(nthIndex(doc.getText(), 'TestPrimitiveObj', 1) + 1),
);

const expected = `type TestPrimitiveObj = { value: string; }`;
assertHover(hover, expected);
});
});
11 changes: 11 additions & 0 deletions extensions/vscode/e2e/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./out",
"rootDir": "./suite",
"types": ["node", "mocha", "vscode"]
},
"include": [
"suite/**/*.ts"
]
}
19 changes: 19 additions & 0 deletions extensions/vscode/e2e/workspace/test.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<script setup lang="ts">
type ServerReadinessProbe = {
a?: string;
};


type StringPrimitive = string;
type TestPrimitiveObj = { value: StringPrimitive };
</script>

<template>
<div>

</div>
</template>

<style scoped>

</style>
20 changes: 20 additions & 0 deletions extensions/vscode/e2e/workspace/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es2017",
"module": "commonjs",
"sourceMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true
},
"include": [
"**/*.ts",
"**/*.d.ts",
"**/*.tsx",
"**/*.vue"
],
"exclude": ["node_modules"]
}
5 changes: 5 additions & 0 deletions extensions/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -473,15 +473,20 @@
},
"scripts": {
"vscode:prepublish": "npm run build",
"pretest": "tsgo --project ./e2e/tsconfig.json && pnpm build",
"test": "vscode-test --config ./e2e/.vscode-test.js",
"build": "rolldown --config",
"pack": "npx @vscode/vsce package",
"gen-ext-meta": "vscode-ext-gen --scope vue --output src/generated-meta.ts && cd ../.. && npm run format"
},
"devDependencies": {
"@types/mocha": "^10.0.10",
"@types/node": "^22.10.4",
"@types/vscode": "1.88.0",
"@volar/typescript": "2.4.27",
"@volar/vscode": "2.4.27",
"@vscode/test-electron": "^2.5.2",
"@vscode/test-cli": "^0.0.12",
"@vue/language-core": "workspace:*",
"@vue/language-server": "workspace:*",
"@vue/typescript-plugin": "workspace:*",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"scripts": {
"build": "tsgo -b",
"watch": "tsgo -b -w",
"test:vscode": "cd extensions/vscode && npm run test",
"test": "npm run build && vitest run",
"test:grammar": "vitest run extensions/vscode/tests/grammar.spec.ts",
"format": "dprint fmt",
Expand Down
Loading