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,9 +2,11 @@ dist
node_modules
*.tsbuildinfo
*.vsix
.vscode-test

extensions/vscode/types
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,
},
});
53 changes: 53 additions & 0 deletions extensions/vscode/e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# E2E Testing Guidelines

## Scope

E2E tests verify **IDE + LSP interaction**, not compiler internals.

### ✅ Test These (LSP Interface Layer)

| Category | Examples |
|----------|----------|
| **Hover Info** | ref/props/computed type display in templates |
| **Diagnostics** | Error messages and positions in IDE |
| **Completions** | Auto-complete lists for props, events, methods |
| **Navigation** | Go to Definition, Find References |
| **Multi-file** | Cross-file type inference via LSP |

### ❌ Don't Test These (Unit Test Responsibility)

- Type inference logic (belongs in `@vue/language-core` tests)
- Props/Emits type constraints
- Ref/Reactive/Computed type systems
- Generic type resolution
- Template expression type narrowing

## Test Structure

```
suite/
├── hover.e2e-test.ts # Hover information accuracy
├── diagnostics.e2e-test.ts # Error display & position
├── completions.e2e-test.ts # Auto-complete lists
└── [scenario].e2e-test.ts # Other LSP features
```

## Utils API

Use `utils.ts` helpers:

```typescript
await getDiagnostics(fileName?, waitMs?) // LSP diagnostics
await getHover(getPosition) // Hover info
await getCompletions(getPosition) // Completion items
await goToDefinition(getPosition) // Definition location
await findReferences(getPosition) // Reference locations
await modifyFile(fileName, modifyFn) // File changes + test
```

## Key Principle

**Test the user's IDE experience, not compiler correctness.**

If the question is "Does the IDE show correct information?", it's E2E.
If the question is "Does the compiler infer the type correctly?", it's unit test.
46 changes: 46 additions & 0 deletions extensions/vscode/e2e/suite/completions.e2e-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import * as assert from 'node:assert';
import { ensureTypeScriptServerReady, getCompletions, openDocument } from './utils';

suite('Completions', () => {
suiteSetup(async function() {
await ensureTypeScriptServerReady('completions-test.vue', 'text');
});

test('string method completions available after member access', async () => {
await openDocument('completions-test.vue');

const completions = await getCompletions(doc => {
// Find "text." position
const textDotPos = doc.getText().indexOf('{{ text.');
// Position after the dot
return doc.positionAt(textDotPos + 8);
});

const labels = completions.map(c => typeof c.label === 'string' ? c.label : c.label.label);

// Should include common string methods
assert.ok(
labels.includes('toUpperCase') || labels.some(l => l.startsWith('toUpperCase')),
`Expected 'toUpperCase' in completions, got: ${labels.slice(0, 10).join(', ')}`,
);
assert.ok(
labels.includes('toLowerCase') || labels.some(l => l.startsWith('toLowerCase')),
`Expected 'toLowerCase' in completions`,
);
assert.ok(
labels.includes('slice') || labels.some(l => l.startsWith('slice')),
`Expected 'slice' in completions`,
);
});

test('completions not empty', async () => {
await openDocument('completions-test.vue');

const completions = await getCompletions(doc => {
const textDotPos = doc.getText().indexOf('{{ text.');
return doc.positionAt(textDotPos + 8);
});

assert.ok(completions.length > 0, 'Should have at least one completion');
});
});
54 changes: 54 additions & 0 deletions extensions/vscode/e2e/suite/diagnostics.e2e-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import * as assert from 'node:assert';
import { assertDiagnostic, ensureTypeScriptServerReady, getDiagnostics, openDocument } from './utils';

suite('Template Diagnostics', () => {
suiteSetup(async function() {
await ensureTypeScriptServerReady('template-diagnostics.vue', 'ServerReadinessProbe');
});

test('template type error - number has no toUpperCase method', async () => {
await openDocument('template-diagnostics.vue');
const diagnostics = await getDiagnostics();

// Should have a diagnostic about toUpperCase not existing on number
const error = assertDiagnostic(diagnostics, 'toUpperCase');

// Verify the diagnostic position is near the method call
assert.ok(error.range, 'Diagnostic should have a range');
});

test('template type error - string has no toFixed method', async () => {
await openDocument('template-diagnostics.vue');
const diagnostics = await getDiagnostics();

// Should have a diagnostic about toFixed not existing on string
const error = assertDiagnostic(diagnostics, 'toFixed');

assert.ok(error.range, 'Diagnostic should have a range');
});

test('template diagnostics have clear error messages', async () => {
await openDocument('template-diagnostics.vue');
const diagnostics = await getDiagnostics();

// Filter for our specific errors
const relevantDiags = diagnostics.filter(d =>
typeof d.message === 'string'
&& (d.message.includes('toUpperCase') || d.message.includes('toFixed'))
);

assert.ok(relevantDiags.length > 0, 'Should have at least one relevant diagnostic');

// Messages should mention the property or method
relevantDiags.forEach(diag => {
const message = typeof diag.message === 'string' ? diag.message : '';
assert.ok(
message.includes('property')
|| message.includes('method')
|| message.includes('does not exist')
|| message.includes('no such'),
`Message should be clear about what's missing: "${message}"`,
);
});
});
});
83 changes: 83 additions & 0 deletions extensions/vscode/e2e/suite/hover.e2e-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import * as assert from 'node:assert';
import { ensureTypeScriptServerReady, getHover, nthIndex, openDocument } from './utils';

suite('Hover Type Display', () => {
suite('ref unwrapping', () => {
suiteSetup(async function() {
await ensureTypeScriptServerReady('refs-hover.vue', 'count');
});

test('ref(0) shows number type in template, not Ref<number>', async () => {
await openDocument('refs-hover.vue');

const hover = await getHover(doc => doc.positionAt(nthIndex(doc.getText(), '{{ count }}', 1) + 3));

// Should show 'number', not 'Ref<number>'
const hoverText = hover.join('\n');
assert.ok(hoverText.includes('number'), `Expected to see 'number' in hover, got: ${hoverText}`);
assert.ok(!hoverText.includes('Ref'), `Should not see 'Ref' in hover for template interpolation`);
});

test('ref("hello") shows string type in template', async () => {
await openDocument('refs-hover.vue');

const hover = await getHover(doc => doc.positionAt(nthIndex(doc.getText(), '{{ message }}', 1) + 3));

const hoverText = hover.join('\n');
assert.ok(hoverText.includes('string'), `Expected to see 'string' in hover, got: ${hoverText}`);
assert.ok(!hoverText.includes('Ref'), `Should not see 'Ref' in hover`);
});
});

suite('props hover', () => {
suiteSetup(async function() {
await ensureTypeScriptServerReady('props-hover.vue', 'title');
});

test('props show correct type in template', async () => {
await openDocument('props-hover.vue');

const hover = await getHover(doc => doc.positionAt(nthIndex(doc.getText(), '{{ title }}', 1) + 3));

const hoverText = hover.join('\n');
assert.ok(hoverText.includes('string'), `Expected 'string' type in hover for title prop`);
});

test('optional props show union with undefined', async () => {
await openDocument('props-hover.vue');

const hover = await getHover(doc => doc.positionAt(nthIndex(doc.getText(), 'disabled', 1)));

const hoverText = hover.join('\n');
assert.ok(
hoverText.includes('boolean') && hoverText.includes('undefined'),
`Expected 'boolean | undefined' for optional prop, got: ${hoverText}`,
);
});

test('required number prop shows number type', async () => {
await openDocument('props-hover.vue');

const hover = await getHover(doc => doc.positionAt(nthIndex(doc.getText(), '{{ count }}', 1) + 3));

const hoverText = hover.join('\n');
assert.ok(hoverText.includes('number'), `Expected 'number' type for count prop`);
});
});

suite('computed unwrapping', () => {
suiteSetup(async function() {
await ensureTypeScriptServerReady('computed-hover.vue', 'double');
});

test('computed shows return value type, not Computed<T>', async () => {
await openDocument('computed-hover.vue');

const hover = await getHover(doc => doc.positionAt(nthIndex(doc.getText(), '{{ double }}', 1) + 3));

const hoverText = hover.join('\n');
assert.ok(hoverText.includes('number'), `Expected 'number' return type in hover`);
assert.ok(!hoverText.includes('Computed'), `Should not see 'Computed<T>' in template hover`);
});
});
});
54 changes: 54 additions & 0 deletions extensions/vscode/e2e/suite/props-diagnostics.e2e-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import * as assert from 'node:assert';
import { assertDiagnostic, ensureTypeScriptServerReady, getDiagnostics, openDocument } from './utils';

suite('Props Type Checking Diagnostics', () => {
suiteSetup(async function() {
await ensureTypeScriptServerReady('parent.vue', 'Child');
});

test('missing required props show diagnostic', async () => {
await openDocument('parent.vue');
const diagnostics = await getDiagnostics();

// Should have a diagnostic about missing 'title' prop on first Child usage
const error = assertDiagnostic(
diagnostics,
'title',
':count="123"', // Check it's on the first Child line
);

assert.ok(error, 'Should have diagnostic for missing required prop');
});

test('props type mismatch shows diagnostic', async () => {
await openDocument('parent.vue');
const diagnostics = await getDiagnostics();

// Should have a diagnostic about count type mismatch (string vs number)
const relevantDiags = diagnostics.filter(d => {
const msg = typeof d.message === 'string' ? d.message : '';
return msg.includes('count') || msg.includes('string') || msg.includes('number');
});

assert.ok(
relevantDiags.length > 0,
'Should have diagnostic for type mismatch on count prop',
);
});

test('both errors present for multi-error case', async () => {
await openDocument('parent.vue');
const diagnostics = await getDiagnostics();

// We should have multiple error diagnostics for the Child components
const childErrors = diagnostics.filter(d => {
const msg = typeof d.message === 'string' ? d.message : '';
return msg.includes('Child') || msg.includes('title') || msg.includes('count');
});

assert.ok(
childErrors.length >= 1,
`Should have at least 1 diagnostic for Child prop errors, got ${childErrors.length}`,
);
});
});
Loading