Improved testing infrastructure (#1487)

* Added VS Code mock to turn e2e into unit/integration tests

* Provide fallback to editor directory when creating new note with relative path

* Added `clear` function to `FoamWorkspace`

* Fixed tests for dated notes by providing configuration defaults

* Using different workspace directory when resetting mock

* tweaked test suite configuration to manage vscode mock

* Tweaked test scripts to allow running specs in "unit" mode with mock vscode environment

* Marked spec files that can be run in unit mode

* Added testing documentation

* removed --stream flag 

* updated @types/node to match engine's version

* Fixing open-resource tests
This commit is contained in:
Riccardo
2025-07-17 16:47:30 +02:00
committed by GitHub
parent c669e5436b
commit 7a10c45ed8
31 changed files with 1988 additions and 43 deletions

View File

@@ -73,4 +73,4 @@ jobs:
- name: Run Tests
uses: GabrielBB/xvfb-action@v1.4
with:
run: yarn test --stream
run: yarn test

View File

@@ -26,7 +26,7 @@
"editor.defaultFormatter": "esbenp.prettier-vscode",
"jest.autoRun": "off",
"jest.rootPath": "packages/foam-vscode",
"jest.jestCommandLine": "yarn jest",
"jest.jestCommandLine": "yarn test:unit-with-specs",
"gitdoc.enabled": false,
"search.mode": "reuseEditor",
"[typescript]": {

130
docs/dev/testing.md Normal file
View File

@@ -0,0 +1,130 @@
# Testing in Foam VS Code Extension
This document explains the testing strategy and conventions used in the Foam VS Code extension.
## Test File Types
We use two distinct types of test files, each serving different purposes:
### `.test.ts` Files - Pure Unit Tests
- **Purpose**: Test business logic and algorithms in complete isolation
- **Dependencies**: No VS Code APIs dependencies
- **Environment**: Pure Jest with Node.js
- **Speed**: Very fast execution
- **Location**: Throughout the codebase alongside source files
### `.spec.ts` Files - Integration Tests with VS Code APIs
- **Purpose**: Test features that integrate with VS Code APIs and user workflows
- **Dependencies**: Will likely depend on VS Code APIs (`vscode` module), otherwise avoid incurring the performance hit
- **Environment**: Can run in TWO environments:
- **Mock Environment**: Jest with VS Code API mocks (fast)
- **Real VS Code**: Full VS Code extension host (slow but comprehensive)
- **Speed**: Depends on environment (see performance section below)
- **Location**: Primarily in `src/features/` and service layers
## Key Principle: Environment Flexibility for `.spec.ts` Files
**`.spec.ts` files use VS Code APIs**, but they can run in different environments:
- **Mock Environment**: Uses our VS Code API mocks for speed
- **Real VS Code**: Uses actual VS Code extension host for full integration testing
This dual-environment capability allows us to:
- Run specs quickly during development (mock environment)
- Verify full integration during CI/CD (real VS Code environment)
- Gradually migrate specs to mock-compatible implementations
## Performance Comparison
| Test Type | Environment | Typical Duration | VS Code APIs |
| --------------------- | ---------------------- | ---------------- | ---------------- |
| **`.test.ts`** | Pure Jest | fastest | **No** |
| **`.spec.ts` (mock)** | Jest + VS Code Mocks | fast | **Yes** (mocked) |
| **`.spec.ts` (real)** | VS Code Extension Host | sloooooow. | **Yes** (real) |
## Running Tests
### Available Commands
- **`yarn test:unit`**: Runs only `.test.ts` files (no VS Code dependencies)
- **`yarn test:unit-with-specs`**: Runs `.test.ts` + `@unit-ready` marked `.spec.ts` files using mocks
- **`yarn test:e2e`**: Runs all `.spec.ts` files in full VS Code extension host
- **`yarn test`**: Runs both unit and e2e test suites sequentially
## Mock Environment Migration
We're gradually enabling `.spec.ts` files to run in our fast mock environment while maintaining their ability to run in real VS Code.
### The `@unit-ready` Annotation
Spec files marked with `/* @unit-ready */` can run in both environments:
```typescript
/* @unit-ready */
import * as vscode from 'vscode';
// ... test uses VS Code APIs but works with our mocks
```
### Common Migration Fixes
**Configuration defaults**: Our mocks don't load package.json defaults
```typescript
// Before
const format = getFoamVsCodeConfig('openDailyNote.filenameFormat');
// After (defensive)
const format = getFoamVsCodeConfig(
'openDailyNote.filenameFormat',
'yyyy-mm-dd'
);
```
**File system operations**: Ensure proper async handling
```typescript
// Mock file operations are immediate but still async
await vscode.workspace.fs.writeFile(uri, content);
```
### When NOT to Migrate
Some specs should remain real-VS-Code-only:
- Tests verifying complex VS Code UI interactions
- Tests requiring real file system watching with timing
- Tests validating extension packaging or activation
- Tests that depend on VS Code's complex internal state management
## Mock System Capabilities
Our `vscode-mock.ts` provides comprehensive VS Code API mocking:
## Contributing Guidelines
When adding new tests:
1. **Choose the right type**:
- Use `.test.ts` for pure business logic with no VS Code dependencies
- Use `.spec.ts` for anything that needs VS Code APIs
2. **Consider mock compatibility**:
- When writing `.spec.ts` files, consider if they could run in mock environment
- Add `/* @unit-ready */` if the test works with our mocks
3. **Follow naming conventions**:
- Test files should be co-located with source files when possible
- Use descriptive test names that explain the expected behavior
4. **Performance awareness**:
- Prefer unit tests for business logic (fastest)
- Use mock-compatible specs for VS Code integration (fast)
- Reserve real VS Code specs for complex integration scenarios (comprehensive)
This testing strategy gives us the best of both worlds: fast feedback during development and comprehensive integration verification when needed.

View File

@@ -16,9 +16,9 @@
"reset": "yarn && yarn clean && yarn build",
"clean": "lerna run clean",
"build": "lerna run build",
"test": "yarn workspace foam-vscode test --stream",
"test": "yarn workspace foam-vscode test",
"lint": "lerna run lint",
"watch": "lerna run watch --concurrency 20 --stream"
"watch": "lerna run watch --concurrency 20"
},
"devDependencies": {
"all-contributors-cli": "^6.16.1",

View File

@@ -123,7 +123,7 @@ module.exports = {
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
setupFiles: ['<rootDir>/src/test/support/jest-setup.ts'],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
setupFilesAfterEnv: ['jest-extended'],
@@ -153,9 +153,8 @@ module.exports = {
// The regexp pattern or array of patterns that Jest uses to detect test files
// This is overridden in every runCLI invocation but it's here as the default
// for vscode-jest. We only want unit tests in the test explorer (sidebar),
// since spec tests require the entire extension host to be launched before.
testRegex: ['\\.test\\.ts$'],
// for vscode-jest. Both .test.ts and .spec.ts files use the vscode-mock.
testRegex: ['\\.(test|spec)\\.ts$'],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,

View File

@@ -678,7 +678,8 @@
"test-reset-workspace": "rm -rf .test-workspace && mkdir .test-workspace && touch .test-workspace/.keep",
"test-setup": "yarn compile && yarn build && yarn test-reset-workspace",
"test": "yarn test-setup && node ./out/test/run-tests.js",
"test:unit": "yarn test-setup && node ./out/test/run-tests.js --unit",
"test:unit": "yarn test-setup && node ./out/test/run-tests.js --unit --exclude-specs",
"test:unit-with-specs": "yarn test-setup && node ./out/test/run-tests.js --unit",
"test:e2e": "yarn test-setup && node ./out/test/run-tests.js --e2e",
"lint": "dts lint src",
"clean": "rimraf out",
@@ -697,7 +698,7 @@
"@types/lodash": "^4.14.157",
"@types/markdown-it": "^12.0.1",
"@types/micromatch": "^4.0.1",
"@types/node": "^13.11.0",
"@types/node": "^18.0.0",
"@types/picomatch": "^2.2.1",
"@types/remove-markdown": "^0.1.1",
"@types/vscode": "^1.70.0",

View File

@@ -52,6 +52,16 @@ export class FoamWorkspace implements IDisposable {
return deleted ?? null;
}
clear() {
const resources = Array.from(this._resources.values());
this._resources.clear();
// Fire delete events for all resources
resources.forEach(resource => {
this.onDidDeleteEmitter.fire(resource);
});
}
public exists(uri: URI): boolean {
return isSome(this.find(uri));
}

View File

@@ -1,3 +1,4 @@
/* @unit-ready */
import { workspace } from 'vscode';
import { createDailyNoteIfNotExists, getDailyNoteUri } from './dated-notes';
import { isWindows } from './core/common/platform';

View File

@@ -54,10 +54,12 @@ export function getDailyNoteUri(date: Date): URI {
*/
export function getDailyNoteFileName(date: Date): string {
const filenameFormat: string = getFoamVsCodeConfig(
'openDailyNote.filenameFormat'
'openDailyNote.filenameFormat',
'yyyy-mm-dd'
);
const fileExtension: string = getFoamVsCodeConfig(
'openDailyNote.fileExtension'
'openDailyNote.fileExtension',
'md'
);
return `${dateFormat(date, filenameFormat, false)}.${fileExtension}`;

View File

@@ -1,3 +1,4 @@
/* @unit-ready */
import { env, Position, Selection, commands } from 'vscode';
import { createFile, showInEditor } from '../../test/test-utils-vscode';
import { removeBrackets, toTitleCase } from './copy-without-brackets';

View File

@@ -1,3 +1,4 @@
/* @unit-ready */
import { commands, window, workspace } from 'vscode';
import { toVsCodeUri } from '../../utils/vsc-utils';
import { createFile } from '../../test/test-utils-vscode';

View File

@@ -1,3 +1,4 @@
/* @unit-ready */
import { commands, window, workspace } from 'vscode';
import { URI } from '../../core/model/uri';
import { asAbsoluteWorkspaceUri, readFile } from '../../services/editor';

View File

@@ -1,3 +1,4 @@
/* @unit-ready */
import dateFormat from 'dateformat';
import { commands, window } from 'vscode';

View File

@@ -3,19 +3,28 @@ import { CommandDescriptor } from '../../utils/commands';
import { OpenResourceArgs, OPEN_COMMAND } from './open-resource';
import * as filter from '../../core/services/resource-filter';
import { URI } from '../../core/model/uri';
import { closeEditors, createFile } from '../../test/test-utils-vscode';
import {
closeEditors,
createFile,
waitForNoteInFoamWorkspace,
} from '../../test/test-utils-vscode';
import { deleteFile } from '../../services/editor';
import waitForExpect from 'wait-for-expect';
describe('open-resource command', () => {
beforeEach(async () => {
await jest.resetAllMocks();
jest.resetAllMocks();
await closeEditors();
});
afterEach(async () => {
await closeEditors();
});
it('URI param has precedence over filter', async () => {
const spy = jest.spyOn(filter, 'createFilter');
const noteA = await createFile('Note A for open command');
await waitForNoteInFoamWorkspace(noteA.uri);
const command: CommandDescriptor<OpenResourceArgs> = {
name: OPEN_COMMAND.command,
@@ -26,7 +35,8 @@ describe('open-resource command', () => {
};
await commands.executeCommand(command.name, command.params);
waitForExpect(() => {
await waitForExpect(() => {
expect(window.activeTextEditor).toBeTruthy();
expect(window.activeTextEditor.document.uri.path).toEqual(noteA.uri.path);
});
expect(spy).not.toHaveBeenCalled();
@@ -36,15 +46,17 @@ describe('open-resource command', () => {
it('URI param accept URI object, or path', async () => {
const noteA = await createFile('Note A for open command');
await waitForNoteInFoamWorkspace(noteA.uri);
const uriCommand: CommandDescriptor<OpenResourceArgs> = {
name: OPEN_COMMAND.command,
params: {
uri: URI.file('path/to/file.md'),
uri: noteA.uri,
},
};
await commands.executeCommand(uriCommand.name, uriCommand.params);
waitForExpect(() => {
await waitForExpect(() => {
expect(window.activeTextEditor).toBeTruthy();
expect(window.activeTextEditor.document.uri.path).toEqual(noteA.uri.path);
});
@@ -53,17 +65,18 @@ describe('open-resource command', () => {
const pathCommand: CommandDescriptor<OpenResourceArgs> = {
name: OPEN_COMMAND.command,
params: {
uri: URI.file('path/to/file.md'),
uri: noteA.uri.path,
},
};
await commands.executeCommand(pathCommand.name, pathCommand.params);
waitForExpect(() => {
await waitForExpect(() => {
expect(window.activeTextEditor).toBeTruthy();
expect(window.activeTextEditor.document.uri.path).toEqual(noteA.uri.path);
});
await deleteFile(noteA.uri);
});
it('User is notified if no resource is found', async () => {
it('User is notified if no resource is found with filter', async () => {
const spy = jest.spyOn(window, 'showInformationMessage');
const command: CommandDescriptor<OpenResourceArgs> = {
@@ -74,12 +87,33 @@ describe('open-resource command', () => {
};
await commands.executeCommand(command.name, command.params);
waitForExpect(() => {
await waitForExpect(() => {
expect(spy).toHaveBeenCalled();
});
});
it('User is notified if no resource is found with URI', async () => {
const spy = jest.spyOn(window, 'showInformationMessage');
const command: CommandDescriptor<OpenResourceArgs> = {
name: OPEN_COMMAND.command,
params: {
uri: URI.file('path/to/nonexistent.md'),
},
};
await commands.executeCommand(command.name, command.params);
await waitForExpect(() => {
expect(spy).toHaveBeenCalled();
});
});
it('filter with multiple results will show a quick pick', async () => {
const noteA = await createFile('Note A for filter test');
const noteB = await createFile('Note B for filter test');
await waitForNoteInFoamWorkspace(noteA.uri);
await waitForNoteInFoamWorkspace(noteB.uri);
const spy = jest
.spyOn(window, 'showQuickPick')
.mockImplementationOnce(jest.fn(() => Promise.resolve(undefined)));
@@ -92,8 +126,11 @@ describe('open-resource command', () => {
};
await commands.executeCommand(command.name, command.params);
waitForExpect(() => {
await waitForExpect(() => {
expect(spy).toHaveBeenCalled();
});
await deleteFile(noteA.uri);
await deleteFile(noteB.uri);
});
});

View File

@@ -82,13 +82,18 @@ async function openResource(workspace: FoamWorkspace, args?: OpenResourceArgs) {
);
}
if (isSome(item)) {
const targetUri =
item.uri.path === vscode.window.activeTextEditor?.document.uri.path
? vscode.window.activeTextEditor?.document.uri
: toVsCodeUri(item.uri.asPlain());
return vscode.commands.executeCommand('vscode.open', targetUri);
if (isNone(item)) {
vscode.window.showInformationMessage(
'Foam: No note matches given filters or URI.'
);
return;
}
const targetUri =
item.uri.path === vscode.window.activeTextEditor?.document.uri.path
? vscode.window.activeTextEditor?.document.uri
: toVsCodeUri(item.uri.asPlain());
return vscode.commands.executeCommand('vscode.open', targetUri);
}
interface ResourceItem extends vscode.QuickPickItem {

View File

@@ -1,3 +1,4 @@
/* @unit-ready */
import { workspace, window } from 'vscode';
import { createTestNote, createTestWorkspace } from '../../test/test-utils';
import {

View File

@@ -1,3 +1,4 @@
/* @unit-ready */
import { createTestNote } from '../../test/test-utils';
import { cleanWorkspace, closeEditors } from '../../test/test-utils-vscode';
import { TagItem, TagsProvider } from './tags-explorer';

View File

@@ -1,3 +1,4 @@
/* @unit-ready */
import MarkdownIt from 'markdown-it';
import { FoamWorkspace } from '../../core/model/workspace';
import { default as markdownItFoamTags } from './tag-highlight';

View File

@@ -1,3 +1,4 @@
/* @unit-ready */
import MarkdownIt from 'markdown-it';
import { FoamWorkspace } from '../../core/model/workspace';
import { createMarkdownParser } from '../../core/services/markdown-parser';

View File

@@ -1,3 +1,4 @@
/* @unit-ready */
import MarkdownIt from 'markdown-it';
import { FoamWorkspace } from '../../core/model/workspace';
import { createTestNote } from '../../test/test-utils';

View File

@@ -188,7 +188,7 @@ export async function readFile(uri: URI): Promise<string | undefined> {
if (await fileExists(uri)) {
return workspace.fs
.readFile(toVsCodeUri(uri))
.then(bytes => bytes.toString());
.then(bytes => new TextDecoder('utf-8').decode(bytes));
}
return undefined;
}

View File

@@ -223,7 +223,11 @@ const createFnForOnRelativePathStrategy =
switch (onRelativePath) {
case 'resolve-from-current-dir':
return getCurrentEditorDirectory().joinPath(existingFile.path);
try {
return getCurrentEditorDirectory().joinPath(existingFile.path);
} catch (e) {
return asAbsoluteWorkspaceUri(existingFile);
}
case 'resolve-from-root':
return asAbsoluteWorkspaceUri(existingFile);
case 'cancel':

View File

@@ -1,3 +1,4 @@
/* @unit-ready */
import { getNotesExtensions } from './settings';
import { withModifiedFoamConfiguration } from './test/test-utils-vscode';

View File

@@ -2,28 +2,36 @@ import path from 'path';
import { runTests } from 'vscode-test';
import { runUnit } from './suite-unit';
function parseArgs(): { unit: boolean; e2e: boolean; jestArgs: string[] } {
function parseArgs(): {
unit: boolean;
e2e: boolean;
excludeSpecs: boolean;
jestArgs: string[];
} {
const args = process.argv.slice(2);
const unit = args.some(arg => arg === '--unit');
const e2e = args.some(arg => arg === '--e2e');
const unit = args.includes('--unit');
const e2e = args.includes('--e2e');
const excludeSpecs = args.includes('--exclude-specs');
// Filter out our custom flags and pass the rest to Jest
const jestArgs = args.filter(arg => arg !== '--unit' && arg !== '--e2e');
const jestArgs = args.filter(
arg => !['--unit', '--e2e', '--exclude-specs'].includes(arg)
);
return unit || e2e
? { unit, e2e, jestArgs }
: { unit: true, e2e: true, jestArgs };
? { unit, e2e, excludeSpecs, jestArgs }
: { unit: true, e2e: true, excludeSpecs, jestArgs };
}
async function main() {
const { unit, e2e, jestArgs } = parseArgs();
const { unit, e2e, excludeSpecs, jestArgs } = parseArgs();
let isSuccess = true;
if (unit) {
try {
console.log('Running unit tests');
await runUnit(jestArgs);
await runUnit(jestArgs, excludeSpecs);
} catch (err) {
console.log('Error occurred while running Foam unit tests:', err);
isSuccess = false;

View File

@@ -18,10 +18,43 @@ process.env.NODE_ENV = 'test';
// eslint-disable-next-line import/no-extraneous-dependencies
import { runCLI } from '@jest/core';
import path from 'path';
import * as fs from 'fs';
// eslint-disable-next-line import/no-extraneous-dependencies
import * as glob from 'glob';
const rootDir = path.join(__dirname, '..', '..');
export function runUnit(extraArgs: string[] = []): Promise<void> {
function getUnitReadySpecFiles(rootDir: string): string[] {
const specFiles = glob.sync('**/*.spec.ts', {
cwd: path.join(rootDir, 'src'),
});
const unitReadyFiles: string[] = [];
for (const file of specFiles) {
const fullPath = path.join(rootDir, 'src', file);
try {
const content = fs.readFileSync(fullPath, 'utf8');
// Check for @unit-ready annotation in file
if (
content.includes('/* @unit-ready */') ||
content.includes('// @unit-ready')
) {
unitReadyFiles.push(file);
}
} catch (error) {
// Skip files that can't be read
continue;
}
}
return unitReadyFiles;
}
export function runUnit(
extraArgs: string[] = [],
excludeSpecs = false
): Promise<void> {
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject) => {
try {
@@ -30,8 +63,24 @@ export function runUnit(extraArgs: string[] = []): Promise<void> {
rootDir,
roots: ['<rootDir>/src'],
runInBand: true,
testRegex: '\\.(test)\\.ts$',
testRegex: excludeSpecs
? ['\\.(test)\\.ts$']
: (() => {
const unitReadySpecs = getUnitReadySpecFiles(rootDir);
// Create pattern that includes .test files + specific .spec files
return [
'\\.(test)\\.ts$', // All .test files
...unitReadySpecs.map(
file =>
file.replace(/\//g, '\\/').replace(/\./g, '\\.') + '$'
),
];
})(),
setupFiles: ['<rootDir>/src/test/support/jest-setup.ts'],
setupFilesAfterEnv: [
'<rootDir>/src/test/support/jest-setup-after-env.ts',
],
testTimeout: 20000,
verbose: false,
silent: false,

View File

@@ -58,7 +58,7 @@ export function run(): Promise<void> {
runInBand: true,
testRegex: '\\.(test|spec)\\.ts$',
testEnvironment: '<rootDir>/src/test/support/vscode-environment.js',
setupFiles: ['<rootDir>/src/test/support/jest-setup.ts'],
setupFiles: ['<rootDir>/src/test/support/jest-setup-e2e.ts'],
testTimeout: 30000,
useStderr: true,
verbose: true,

View File

@@ -0,0 +1,18 @@
// This file runs in the test environment where Jest globals are available
// Clean up after each test file to prevent hanging threads
afterAll(async () => {
const vscode = require('../vscode-mock');
// Force cleanup of any async operations
if (vscode.forceCleanup) {
await vscode.forceCleanup();
}
// Force garbage collection if available
if (global.gc) {
global.gc();
}
// Wait for any remaining async operations to complete
await new Promise(resolve => setImmediate(resolve));
});

View File

@@ -0,0 +1,2 @@
// Based on https://github.com/svsool/vscode-memo/blob/master/src/test/config/jestSetup.ts
jest.mock('vscode', () => (global as any).vscode, { virtual: true });

View File

@@ -1,2 +1 @@
// Based on https://github.com/svsool/vscode-memo/blob/master/src/test/config/jestSetup.ts
jest.mock('vscode', () => (global as any).vscode, { virtual: true });
jest.mock('vscode', () => require('../vscode-mock'), { virtual: true });

View File

@@ -9,6 +9,8 @@ import { Logger } from '../core/utils/log';
import { URI } from '../core/model/uri';
import { Resource } from '../core/model/note';
import { randomString, wait } from './test-utils';
import { FoamWorkspace } from '../core/model/workspace';
import { Foam } from '../core/model/foam';
Logger.setLevel('error');
@@ -53,6 +55,35 @@ export const getUriInWorkspace = (...filepath: string[]) => {
return uri;
};
export const getFoamFromVSCode = async (): Promise<Foam> => {
// In test environment, try different extension IDs
const extension = vscode.extensions.getExtension('foam.foam-vscode');
const exports = extension.isActive
? extension.exports
: await extension.activate();
if (!exports || !exports.foam) {
throw new Error('Foam not available in extension exports');
}
return exports.foam;
};
export const waitForNoteInFoamWorkspace = async (uri: URI, timeout = 5000) => {
const start = Date.now();
const foam = await getFoamFromVSCode();
const workspace = foam.workspace;
// Wait for the workspace to discover the note
while (Date.now() - start < timeout) {
if (workspace.find(uri.path)) {
return true;
}
await wait(100);
}
return false;
};
/**
* Creates a file with a some content.
*

File diff suppressed because it is too large Load Diff