diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70dc38d1..c73d4831 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,4 +73,4 @@ jobs: - name: Run Tests uses: GabrielBB/xvfb-action@v1.4 with: - run: yarn test --stream + run: yarn test diff --git a/.vscode/settings.json b/.vscode/settings.json index 16c0c76e..1be7c6d4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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]": { diff --git a/docs/dev/testing.md b/docs/dev/testing.md new file mode 100644 index 00000000..7940eac1 --- /dev/null +++ b/docs/dev/testing.md @@ -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. diff --git a/package.json b/package.json index 5e72cf6f..eb3e1b93 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/foam-vscode/jest.config.js b/packages/foam-vscode/jest.config.js index 7febf589..aeda284b 100644 --- a/packages/foam-vscode/jest.config.js +++ b/packages/foam-vscode/jest.config.js @@ -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: ['/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, diff --git a/packages/foam-vscode/package.json b/packages/foam-vscode/package.json index 7b46d501..06dcdec2 100644 --- a/packages/foam-vscode/package.json +++ b/packages/foam-vscode/package.json @@ -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", diff --git a/packages/foam-vscode/src/core/model/workspace.ts b/packages/foam-vscode/src/core/model/workspace.ts index 8ac897a0..49f84549 100644 --- a/packages/foam-vscode/src/core/model/workspace.ts +++ b/packages/foam-vscode/src/core/model/workspace.ts @@ -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)); } diff --git a/packages/foam-vscode/src/dated-notes.spec.ts b/packages/foam-vscode/src/dated-notes.spec.ts index c795a52c..ee842758 100644 --- a/packages/foam-vscode/src/dated-notes.spec.ts +++ b/packages/foam-vscode/src/dated-notes.spec.ts @@ -1,3 +1,4 @@ +/* @unit-ready */ import { workspace } from 'vscode'; import { createDailyNoteIfNotExists, getDailyNoteUri } from './dated-notes'; import { isWindows } from './core/common/platform'; diff --git a/packages/foam-vscode/src/dated-notes.ts b/packages/foam-vscode/src/dated-notes.ts index cd495fd2..d141d73d 100644 --- a/packages/foam-vscode/src/dated-notes.ts +++ b/packages/foam-vscode/src/dated-notes.ts @@ -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}`; diff --git a/packages/foam-vscode/src/features/commands/copy-without-brackets.spec.ts b/packages/foam-vscode/src/features/commands/copy-without-brackets.spec.ts index 398b5445..0f26f586 100644 --- a/packages/foam-vscode/src/features/commands/copy-without-brackets.spec.ts +++ b/packages/foam-vscode/src/features/commands/copy-without-brackets.spec.ts @@ -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'; diff --git a/packages/foam-vscode/src/features/commands/create-note-from-template.spec.ts b/packages/foam-vscode/src/features/commands/create-note-from-template.spec.ts index 336117eb..303ea21d 100644 --- a/packages/foam-vscode/src/features/commands/create-note-from-template.spec.ts +++ b/packages/foam-vscode/src/features/commands/create-note-from-template.spec.ts @@ -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'; diff --git a/packages/foam-vscode/src/features/commands/create-note.spec.ts b/packages/foam-vscode/src/features/commands/create-note.spec.ts index ca7993b8..cc546510 100644 --- a/packages/foam-vscode/src/features/commands/create-note.spec.ts +++ b/packages/foam-vscode/src/features/commands/create-note.spec.ts @@ -1,3 +1,4 @@ +/* @unit-ready */ import { commands, window, workspace } from 'vscode'; import { URI } from '../../core/model/uri'; import { asAbsoluteWorkspaceUri, readFile } from '../../services/editor'; diff --git a/packages/foam-vscode/src/features/commands/open-daily-note-for-date.spec.ts b/packages/foam-vscode/src/features/commands/open-daily-note-for-date.spec.ts index ae0468ca..9381c2ec 100644 --- a/packages/foam-vscode/src/features/commands/open-daily-note-for-date.spec.ts +++ b/packages/foam-vscode/src/features/commands/open-daily-note-for-date.spec.ts @@ -1,3 +1,4 @@ +/* @unit-ready */ import dateFormat from 'dateformat'; import { commands, window } from 'vscode'; diff --git a/packages/foam-vscode/src/features/commands/open-resource.spec.ts b/packages/foam-vscode/src/features/commands/open-resource.spec.ts index 9d7938b2..f61366a1 100644 --- a/packages/foam-vscode/src/features/commands/open-resource.spec.ts +++ b/packages/foam-vscode/src/features/commands/open-resource.spec.ts @@ -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 = { 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 = { 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 = { 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 = { @@ -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 = { + 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); }); }); diff --git a/packages/foam-vscode/src/features/commands/open-resource.ts b/packages/foam-vscode/src/features/commands/open-resource.ts index 1385e6da..5b4cba64 100644 --- a/packages/foam-vscode/src/features/commands/open-resource.ts +++ b/packages/foam-vscode/src/features/commands/open-resource.ts @@ -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 { diff --git a/packages/foam-vscode/src/features/panels/connections.spec.ts b/packages/foam-vscode/src/features/panels/connections.spec.ts index f6c843b6..0df86cdf 100644 --- a/packages/foam-vscode/src/features/panels/connections.spec.ts +++ b/packages/foam-vscode/src/features/panels/connections.spec.ts @@ -1,3 +1,4 @@ +/* @unit-ready */ import { workspace, window } from 'vscode'; import { createTestNote, createTestWorkspace } from '../../test/test-utils'; import { diff --git a/packages/foam-vscode/src/features/panels/tags-explorer.spec.ts b/packages/foam-vscode/src/features/panels/tags-explorer.spec.ts index c8bb885e..c8c630cf 100644 --- a/packages/foam-vscode/src/features/panels/tags-explorer.spec.ts +++ b/packages/foam-vscode/src/features/panels/tags-explorer.spec.ts @@ -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'; diff --git a/packages/foam-vscode/src/features/preview/tag-highlight.spec.ts b/packages/foam-vscode/src/features/preview/tag-highlight.spec.ts index 145b3515..672ae008 100644 --- a/packages/foam-vscode/src/features/preview/tag-highlight.spec.ts +++ b/packages/foam-vscode/src/features/preview/tag-highlight.spec.ts @@ -1,3 +1,4 @@ +/* @unit-ready */ import MarkdownIt from 'markdown-it'; import { FoamWorkspace } from '../../core/model/workspace'; import { default as markdownItFoamTags } from './tag-highlight'; diff --git a/packages/foam-vscode/src/features/preview/wikilink-embed.spec.ts b/packages/foam-vscode/src/features/preview/wikilink-embed.spec.ts index 6d0ad202..31be1a91 100644 --- a/packages/foam-vscode/src/features/preview/wikilink-embed.spec.ts +++ b/packages/foam-vscode/src/features/preview/wikilink-embed.spec.ts @@ -1,3 +1,4 @@ +/* @unit-ready */ import MarkdownIt from 'markdown-it'; import { FoamWorkspace } from '../../core/model/workspace'; import { createMarkdownParser } from '../../core/services/markdown-parser'; diff --git a/packages/foam-vscode/src/features/preview/wikilink-navigation.spec.ts b/packages/foam-vscode/src/features/preview/wikilink-navigation.spec.ts index 79e4ed16..996b8925 100644 --- a/packages/foam-vscode/src/features/preview/wikilink-navigation.spec.ts +++ b/packages/foam-vscode/src/features/preview/wikilink-navigation.spec.ts @@ -1,3 +1,4 @@ +/* @unit-ready */ import MarkdownIt from 'markdown-it'; import { FoamWorkspace } from '../../core/model/workspace'; import { createTestNote } from '../../test/test-utils'; diff --git a/packages/foam-vscode/src/services/editor.ts b/packages/foam-vscode/src/services/editor.ts index 6269b2e7..cbe4d875 100644 --- a/packages/foam-vscode/src/services/editor.ts +++ b/packages/foam-vscode/src/services/editor.ts @@ -188,7 +188,7 @@ export async function readFile(uri: URI): Promise { if (await fileExists(uri)) { return workspace.fs .readFile(toVsCodeUri(uri)) - .then(bytes => bytes.toString()); + .then(bytes => new TextDecoder('utf-8').decode(bytes)); } return undefined; } diff --git a/packages/foam-vscode/src/services/templates.ts b/packages/foam-vscode/src/services/templates.ts index 5c7a769c..debbd390 100644 --- a/packages/foam-vscode/src/services/templates.ts +++ b/packages/foam-vscode/src/services/templates.ts @@ -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': diff --git a/packages/foam-vscode/src/settings.spec.ts b/packages/foam-vscode/src/settings.spec.ts index c349d469..269162de 100644 --- a/packages/foam-vscode/src/settings.spec.ts +++ b/packages/foam-vscode/src/settings.spec.ts @@ -1,3 +1,4 @@ +/* @unit-ready */ import { getNotesExtensions } from './settings'; import { withModifiedFoamConfiguration } from './test/test-utils-vscode'; diff --git a/packages/foam-vscode/src/test/run-tests.ts b/packages/foam-vscode/src/test/run-tests.ts index 43385351..851ba031 100644 --- a/packages/foam-vscode/src/test/run-tests.ts +++ b/packages/foam-vscode/src/test/run-tests.ts @@ -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; diff --git a/packages/foam-vscode/src/test/suite-unit.ts b/packages/foam-vscode/src/test/suite-unit.ts index bc706522..2a396f9d 100644 --- a/packages/foam-vscode/src/test/suite-unit.ts +++ b/packages/foam-vscode/src/test/suite-unit.ts @@ -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 { +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 { // 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 { rootDir, roots: ['/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: ['/src/test/support/jest-setup.ts'], + setupFilesAfterEnv: [ + '/src/test/support/jest-setup-after-env.ts', + ], testTimeout: 20000, verbose: false, silent: false, diff --git a/packages/foam-vscode/src/test/suite.ts b/packages/foam-vscode/src/test/suite.ts index 285782e9..bc986e23 100644 --- a/packages/foam-vscode/src/test/suite.ts +++ b/packages/foam-vscode/src/test/suite.ts @@ -58,7 +58,7 @@ export function run(): Promise { runInBand: true, testRegex: '\\.(test|spec)\\.ts$', testEnvironment: '/src/test/support/vscode-environment.js', - setupFiles: ['/src/test/support/jest-setup.ts'], + setupFiles: ['/src/test/support/jest-setup-e2e.ts'], testTimeout: 30000, useStderr: true, verbose: true, diff --git a/packages/foam-vscode/src/test/support/jest-setup-after-env.ts b/packages/foam-vscode/src/test/support/jest-setup-after-env.ts new file mode 100644 index 00000000..67980cfb --- /dev/null +++ b/packages/foam-vscode/src/test/support/jest-setup-after-env.ts @@ -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)); +}); diff --git a/packages/foam-vscode/src/test/support/jest-setup-e2e.ts b/packages/foam-vscode/src/test/support/jest-setup-e2e.ts new file mode 100644 index 00000000..450da048 --- /dev/null +++ b/packages/foam-vscode/src/test/support/jest-setup-e2e.ts @@ -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 }); diff --git a/packages/foam-vscode/src/test/support/jest-setup.ts b/packages/foam-vscode/src/test/support/jest-setup.ts index 450da048..0ee2008e 100644 --- a/packages/foam-vscode/src/test/support/jest-setup.ts +++ b/packages/foam-vscode/src/test/support/jest-setup.ts @@ -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 }); diff --git a/packages/foam-vscode/src/test/test-utils-vscode.ts b/packages/foam-vscode/src/test/test-utils-vscode.ts index c5591519..24119424 100644 --- a/packages/foam-vscode/src/test/test-utils-vscode.ts +++ b/packages/foam-vscode/src/test/test-utils-vscode.ts @@ -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 => { + // 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. * diff --git a/packages/foam-vscode/src/test/vscode-mock.ts b/packages/foam-vscode/src/test/vscode-mock.ts new file mode 100644 index 00000000..743b21f4 --- /dev/null +++ b/packages/foam-vscode/src/test/vscode-mock.ts @@ -0,0 +1,1639 @@ +/** + * Mock implementation of VS Code API for testing + * Reuses existing Foam implementations where possible + */ + +import * as os from 'os'; +import * as fs from 'fs'; +import * as path from 'path'; +import { Position } from '../core/model/position'; +import { Range as FoamRange } from '../core/model/range'; +import { URI } from '../core/model/uri'; +import { Logger } from '../core/utils/log'; +import { TextEdit } from '../core/services/text-edit'; +import * as foamCommands from '../features/commands'; +import { Foam, bootstrap } from '../core/model/foam'; +import { createMarkdownParser } from '../core/services/markdown-parser'; +import { + GenericDataStore, + AlwaysIncludeMatcher, +} from '../core/services/datastore'; +import { MarkdownResourceProvider } from '../core/services/markdown-provider'; +import { randomString } from './test-utils'; +import micromatch from 'micromatch'; + +interface Thenable { + then( + onfulfilled?: (value: T) => TResult | Thenable, + onrejected?: (reason: any) => TResult | Thenable + ): Thenable; + then( + onfulfilled?: (value: T) => TResult | Thenable, + onrejected?: (reason: any) => void + ): Thenable; +} + +// ===== Basic VS Code Types ===== + +export { Position }; + +// VS Code Range class +export class Range implements FoamRange { + public readonly start: Position; + public readonly end: Position; + + constructor(start: Position, end: Position); + constructor( + startLine: number, + startCharacter: number, + endLine: number, + endCharacter: number + ); + constructor( + startOrLine: Position | number, + endOrCharacter: Position | number, + endLine?: number, + endCharacter?: number + ) { + if (typeof startOrLine === 'number') { + this.start = { line: startOrLine, character: endOrCharacter as number }; + this.end = { line: endLine!, character: endCharacter! }; + } else { + this.start = startOrLine; + this.end = endOrCharacter as Position; + } + } + + // Add static methods that were being used by other parts of the code + static create( + startLine: number, + startChar: number, + endLine?: number, + endChar?: number + ): Range { + return new Range( + startLine, + startChar, + endLine ?? startLine, + endChar ?? startChar + ); + } + + static createFromPosition(start: Position, end?: Position): Range { + return new Range(start, end ?? start); + } +} + +// Create VS Code-compatible Uri interface that wraps Foam's URI +export interface Uri { + readonly scheme: string; + readonly authority: string; + readonly path: string; + readonly query: string; + readonly fragment: string; + readonly fsPath: string; + + with(change: { + scheme?: string; + authority?: string; + path?: string; + query?: string; + fragment?: string; + }): Uri; + + toString(): string; + toJSON(): any; +} + +// Adapter to convert Foam URI to VS Code Uri +export function createVSCodeUri(foamUri: URI): Uri { + return { + scheme: foamUri.scheme, + authority: foamUri.authority, + path: foamUri.path, + query: foamUri.query, + fragment: foamUri.fragment, + fsPath: foamUri.toFsPath(), + + with(change) { + const newFoamUri = foamUri.with(change); + return createVSCodeUri(newFoamUri); + }, + + toString() { + return foamUri.toString(); + }, + + toJSON() { + return { + scheme: foamUri.scheme, + authority: foamUri.authority, + path: foamUri.path, + query: foamUri.query, + fragment: foamUri.fragment, + fsPath: foamUri.toFsPath(), + }; + }, + }; +} + +// VS Code Uri static methods +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const Uri = { + file(path: string): Uri { + return createVSCodeUri(URI.file(path)); + }, + + parse(value: string): Uri { + return createVSCodeUri(URI.parse(value)); + }, + + from(components: { + scheme: string; + authority?: string; + path?: string; + query?: string; + fragment?: string; + }): Uri { + // Create URI from components + const uriString = `${components.scheme}://${components.authority || ''}${ + components.path || '' + }${components.query ? '?' + components.query : ''}${ + components.fragment ? '#' + components.fragment : '' + }`; + return createVSCodeUri(URI.parse(uriString)); + }, + + joinPath(base: Uri, ...pathSegments: string[]): Uri { + const baseUri = URI.parse(base.toString()); + return createVSCodeUri(baseUri.joinPath(...pathSegments)); + }, +}; + +// Selection extends Range +export class Selection extends Range { + public readonly anchor: Position; + public readonly active: Position; + + constructor(anchor: Position, active: Position); + constructor( + anchorLine: number, + anchorCharacter: number, + activeLine: number, + activeCharacter: number + ); + constructor( + anchorOrLine: Position | number, + activeOrCharacter: Position | number, + activeLine?: number, + activeCharacter?: number + ) { + let anchor: Position; + let active: Position; + + if (typeof anchorOrLine === 'number') { + anchor = { line: anchorOrLine, character: activeOrCharacter as number }; + active = { line: activeLine!, character: activeCharacter! }; + } else { + anchor = anchorOrLine; + active = activeOrCharacter as Position; + } + super(anchor, active); + this.anchor = anchor; + this.active = active; + } + + get isReversed(): boolean { + return Position.isAfter(this.anchor, this.active); + } + + get isEmpty(): boolean { + return Position.isEqual(this.anchor, this.active); + } +} + +// Basic enums +export enum EndOfLine { + LF = 1, + CRLF = 2, +} + +export enum ViewColumn { + Active = -1, + Beside = -2, + One = 1, + Two = 2, + Three = 3, +} + +export enum FileType { + Unknown = 0, + File = 1, + Directory = 2, + SymbolicLink = 64, +} + +export enum CompletionItemKind { + Text = 0, + Method = 1, + Function = 2, + Constructor = 3, + Field = 4, + Variable = 5, + Class = 6, + Interface = 7, + Module = 8, + Property = 9, + Unit = 10, + Value = 11, + Enum = 12, + Keyword = 13, + Snippet = 14, + Color = 15, + File = 16, + Reference = 17, + Folder = 18, + EnumMember = 19, + Constant = 20, + Struct = 21, + Event = 22, + Operator = 23, + TypeParameter = 24, +} + +export enum DiagnosticSeverity { + Error = 0, + Warning = 1, + Information = 2, + Hint = 3, +} + +// ===== Code Actions ===== + +export class CodeActionKind { + public static readonly QuickFix = new CodeActionKind('quickfix'); + public static readonly Refactor = new CodeActionKind('refactor'); + public static readonly RefactorExtract = new CodeActionKind( + 'refactor.extract' + ); + public static readonly RefactorInline = new CodeActionKind('refactor.inline'); + public static readonly RefactorMove = new CodeActionKind('refactor.move'); + public static readonly RefactorRewrite = new CodeActionKind( + 'refactor.rewrite' + ); + public static readonly Source = new CodeActionKind('source'); + public static readonly SourceOrganizeImports = new CodeActionKind( + 'source.organizeImports' + ); + public static readonly SourceFixAll = new CodeActionKind('source.fixAll'); + + constructor(public readonly value: string) {} +} + +export class CodeAction { + public title: string; + public edit?: WorkspaceEdit; + public diagnostics?: any[]; + public kind?: CodeActionKind; + public command?: any; + public isPreferred?: boolean; + public disabled?: { reason: string }; + + constructor(title: string, kind?: CodeActionKind) { + this.title = title; + this.kind = kind; + } +} + +// ===== Completion Items ===== + +export class CompletionItem { + public label: string; + public kind?: CompletionItemKind; + public detail?: string; + public documentation?: string; + public sortText?: string; + public filterText?: string; + public insertText?: string; + public range?: Range; + public command?: any; + public textEdit?: any; + public additionalTextEdits?: any[]; + + constructor(label: string, kind?: CompletionItemKind) { + this.label = label; + this.kind = kind; + } +} + +export class CompletionList { + public isIncomplete: boolean; + public items: CompletionItem[]; + + constructor(items: CompletionItem[] = [], isIncomplete = false) { + this.items = items; + this.isIncomplete = isIncomplete; + } +} + +// ===== Hover ===== + +export class MarkdownString { + public value: string; + public isTrusted?: boolean; + + constructor(value?: string) { + this.value = value || ''; + } + + appendText(value: string): MarkdownString { + this.value += value; + return this; + } + + appendMarkdown(value: string): MarkdownString { + this.value += value; + return this; + } + + appendCodeblock(value: string, language?: string): MarkdownString { + this.value += `\`\`\`${language || ''}\n${value}\n\`\`\``; + return this; + } +} + +export class Hover { + public contents: (MarkdownString | string)[]; + public range?: Range; + + constructor( + contents: (MarkdownString | string)[] | MarkdownString | string, + range?: Range + ) { + if (Array.isArray(contents)) { + this.contents = contents; + } else { + this.contents = [contents]; + } + this.range = range; + } +} + +// ===== Tree Items ===== + +export class TreeItem { + public label?: string; + public id?: string; + public iconPath?: string | Uri | { light: string | Uri; dark: string | Uri }; + public description?: string; + public tooltip?: string; + public command?: any; + public collapsibleState?: number; + public contextValue?: string; + public resourceUri?: Uri; + + constructor(label: string, collapsibleState?: number) { + this.label = label; + this.collapsibleState = collapsibleState; + } +} + +export enum TreeItemCollapsibleState { + None = 0, + Collapsed = 1, + Expanded = 2, +} + +// ===== Theme Classes ===== + +export class ThemeColor { + constructor(public readonly id: string) {} +} + +export class ThemeIcon { + public readonly id: string; + public readonly color?: ThemeColor; + + constructor(id: string, color?: ThemeColor) { + this.id = id; + this.color = color; + } + + static readonly File = new ThemeIcon('file'); + static readonly Folder = new ThemeIcon('folder'); +} + +// ===== Event System ===== + +export interface Event { + (listener: (e: T) => any, thisArg?: any): { dispose(): void }; +} + +export interface Disposable { + dispose(): void; +} + +export class EventEmitter { + private listeners: ((e: T) => any)[] = []; + + get event(): Event { + return (listener: (e: T) => any, thisArg?: any) => { + const boundListener = thisArg ? listener.bind(thisArg) : listener; + this.listeners.push(boundListener); + return { + dispose: () => { + const index = this.listeners.indexOf(boundListener); + if (index >= 0) { + this.listeners.splice(index, 1); + } + }, + }; + }; + } + + fire(data: T): void { + this.listeners.forEach(listener => { + try { + listener(data); + } catch (error) { + console.error('Error in event listener:', error); + } + }); + } + + dispose(): void { + this.listeners = []; + } +} + +// ===== Diagnostics ===== + +export class Diagnostic { + public range: Range; + public message: string; + public severity: DiagnosticSeverity; + public source?: string; + public code?: string | number; + public relatedInformation?: any[]; + + constructor(range: Range, message: string, severity?: DiagnosticSeverity) { + this.range = range; + this.message = message; + this.severity = severity || DiagnosticSeverity.Error; + } +} + +// ===== SnippetString ===== + +export class SnippetString { + public readonly value: string; + + constructor(value?: string) { + this.value = value || ''; + } + + appendText(string: string): SnippetString { + return new SnippetString(this.value + string); + } + + appendTabstop(number?: number): SnippetString { + return new SnippetString(this.value + `$${number || 0}`); + } + + appendPlaceholder( + value: string | ((snippet: SnippetString) => void), + number?: number + ): SnippetString { + const placeholder = typeof value === 'string' ? value : ''; + return new SnippetString(this.value + `\${${number || 1}:${placeholder}}`); + } + + appendChoice(values: string[], number?: number): SnippetString { + return new SnippetString( + this.value + `\${${number || 1}|${values.join(',')}|}` + ); + } + + appendVariable( + name: string, + defaultValue: string | ((snippet: SnippetString) => void) + ): SnippetString { + const def = typeof defaultValue === 'string' ? defaultValue : ''; + return new SnippetString(this.value + `\${${name}:${def}}`); + } +} + +// ===== Configuration ===== + +export interface WorkspaceConfiguration { + get(section: string): T | undefined; + get(section: string, defaultValue: T): T; + has(section: string): boolean; + inspect(section: string): + | { + key: string; + defaultValue?: T; + globalValue?: T; + workspaceValue?: T; + workspaceFolderValue?: T; + } + | undefined; + update( + section: string, + value: any, + configurationTarget?: any + ): Thenable; + [key: string]: any; +} + +class MockWorkspaceConfiguration implements WorkspaceConfiguration { + private _config: Map = new Map(); + + get(section: string, defaultValue?: T): T { + return this._config.get(section) ?? defaultValue; + } + + has(section: string): boolean { + return this._config.has(section); + } + + inspect(section: string): + | { + key: string; + defaultValue?: T; + globalValue?: T; + workspaceValue?: T; + workspaceFolderValue?: T; + } + | undefined { + return { + key: section, + workspaceValue: this._config.get(section), + }; + } + + update( + section: string, + value: any, + configurationTarget?: any + ): Thenable { + this._config.set(section, value); + return Promise.resolve(); + } +} + +// ===== Document Management ===== + +export interface TextLine { + readonly lineNumber: number; + readonly text: string; + readonly range: Range; + readonly rangeIncludingLineBreak: Range; + readonly firstNonWhitespaceCharacterIndex: number; + readonly isEmptyOrWhitespace: boolean; +} + +export interface TextDocument { + readonly uri: Uri; + readonly fileName: string; + readonly isUntitled: boolean; + readonly languageId: string; + readonly version: number; + readonly isDirty: boolean; + readonly isClosed: boolean; + readonly eol: EndOfLine; + readonly lineCount: number; + + save(): Thenable; + getText(range?: Range): string; + lineAt(line: number): TextLine; + lineAt(position: Position): TextLine; + offsetAt(position: Position): number; + positionAt(offset: number): Position; + validatePosition(position: Position): Position; + validateRange(range: Range): Range; + getWordRangeAtPosition(position: Position): Range | undefined; +} + +class MockTextDocument implements TextDocument { + public readonly uri: Uri; + public readonly fileName: string; + public readonly isUntitled: boolean = false; + public readonly languageId: string = 'markdown'; + public readonly version: number = 1; + public readonly isDirty: boolean = false; + public readonly isClosed: boolean = false; + public readonly eol: EndOfLine = EndOfLine.LF; + + private _content: string = ''; + private _lines: string[] = []; + + constructor(uri: Uri, content?: string) { + this.uri = uri; + this.fileName = uri.fsPath; + + if (content !== undefined) { + this._content = content; + // Write the content to file if provided + try { + const dir = path.dirname(uri.fsPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(uri.fsPath, content); + } catch (error) { + // Ignore write errors in mock + } + } else { + // Try to read from file system + try { + this._content = fs.readFileSync(uri.fsPath, 'utf8'); + } catch { + this._content = ''; + } + } + + this._lines = this._content.split(/\r?\n/); + } + + get lineCount(): number { + return Math.max(1, this._lines.length); + } + + async save(): Promise { + try { + await fs.promises.writeFile(this.uri.fsPath, this._content); + return true; + } catch { + return false; + } + } + + getText(range?: Range): string { + // simplify by always returning the full content for now + return this._content; + } + + lineAt(lineOrPosition: number | Position): TextLine { + const lineNumber = + typeof lineOrPosition === 'number' ? lineOrPosition : lineOrPosition.line; + const text = this._lines[lineNumber] || ''; + const range = Range.create(lineNumber, 0, lineNumber, text.length); + const rangeIncludingLineBreak = Range.create( + lineNumber, + 0, + lineNumber + 1, + 0 + ); + + return { + lineNumber, + text, + range, + rangeIncludingLineBreak, + firstNonWhitespaceCharacterIndex: text.search(/\S/), + isEmptyOrWhitespace: text.trim().length === 0, + }; + } + + offsetAt(position: Position): number { + let offset = 0; + for (let i = 0; i < position.line && i < this._lines.length; i++) { + offset += this._lines[i].length + 1; // +1 for newline + } + return ( + offset + + Math.min(position.character, this._lines[position.line]?.length || 0) + ); + } + + positionAt(offset: number): Position { + let currentOffset = 0; + for (let line = 0; line < this._lines.length; line++) { + const lineLength = this._lines[line].length; + if (currentOffset + lineLength >= offset) { + return Position.create(line, offset - currentOffset); + } + currentOffset += lineLength + 1; // +1 for newline + } + return Position.create( + this._lines.length - 1, + this._lines[this._lines.length - 1]?.length || 0 + ); + } + + validatePosition(position: Position): Position { + const line = Math.max(0, Math.min(position.line, this.lineCount - 1)); + const character = Math.max( + 0, + Math.min(position.character, this._lines[line]?.length || 0) + ); + return Position.create(line, character); + } + + validateRange(range: Range): Range { + const start = this.validatePosition(range.start); + const end = this.validatePosition(range.end); + return Range.createFromPosition(start, end); + } + + getWordRangeAtPosition(position: Position): Range | undefined { + const line = this._lines[position.line]; + if (!line) return undefined; + + const wordRegex = /\w+/g; + let match; + while ((match = wordRegex.exec(line)) !== null) { + const start = Position.create(position.line, match.index); + const end = Position.create(position.line, match.index + match[0].length); + if ( + Position.isBeforeOrEqual(start, position) && + Position.isAfterOrEqual(end, position) + ) { + return Range.createFromPosition(start, end); + } + } + return undefined; + } + + // Internal method to update content + _updateContent(content: string): void { + this._content = content; + this._lines = content.split(/\r?\n/); + // Write the content to file immediately so it persists + try { + const dir = path.dirname(this.uri.fsPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(this.uri.fsPath, content); + } catch (error) { + Logger.error('vscode-mock: Failed to write file', error); + } + } +} + +export interface TextEditor { + readonly document: TextDocument; + selection: Selection; + selections: Selection[]; + readonly visibleRanges: Range[]; + readonly viewColumn: ViewColumn | undefined; + + edit(callback: (editBuilder: any) => void): Thenable; + insertSnippet(snippet: any): Thenable; + setDecorations(decorationType: any, ranges: Range[]): void; + revealRange(range: Range): void; + show(column?: ViewColumn): void; + hide(): void; +} + +class MockTextEditor implements TextEditor { + public readonly document: TextDocument; + public selection: Selection; + public selections: Selection[]; + public readonly visibleRanges: Range[] = []; + public readonly viewColumn: ViewColumn | undefined; + + constructor(document: TextDocument, viewColumn?: ViewColumn) { + this.document = document; + this.viewColumn = viewColumn; + this.selection = new Selection(0, 0, 0, 0); + this.selections = [this.selection]; + } + + async edit(callback: (editBuilder: any) => void): Promise { + // Simplified edit implementation + return true; + } + + async insertSnippet(snippet: any): Promise { + // Insert snippet at current selection + if (snippet && typeof snippet === 'object' && snippet.value) { + const text = snippet.value; + const document = this.document as MockTextDocument; + + // Replace selection with snippet text + const startOffset = document.offsetAt(this.selection.start); + const endOffset = document.offsetAt(this.selection.end); + let content = document.getText(); + content = + content.substring(0, startOffset) + text + content.substring(endOffset); + + document._updateContent(content); + + // Move cursor to end of inserted text + const newPosition = document.positionAt(startOffset + text.length); + this.selection = new Selection(newPosition, newPosition); + this.selections = [this.selection]; + } + return true; + } + + setDecorations(decorationType: any, ranges: Range[]): void { + // No-op for mock + } + + revealRange(range: Range): void { + // No-op for mock + } + + show(column?: ViewColumn): void { + // No-op for mock + } + + hide(): void { + // No-op for mock + } +} + +// ===== WorkspaceEdit ===== + +export class WorkspaceEdit { + private _edits: Map = new Map(); + + replace(uri: Uri, range: Range, newText: string): void { + const key = uri.toString(); + if (!this._edits.has(key)) { + this._edits.set(key, []); + } + this._edits.get(key)!.push({ type: 'replace', range, newText }); + } + + insert(uri: Uri, position: Position, newText: string): void { + const key = uri.toString(); + if (!this._edits.has(key)) { + this._edits.set(key, []); + } + this._edits.get(key)!.push({ type: 'insert', position, newText }); + } + + delete(uri: Uri, range: Range): void { + const key = uri.toString(); + if (!this._edits.has(key)) { + this._edits.set(key, []); + } + this._edits.get(key)!.push({ type: 'delete', range }); + } + + renameFile( + oldUri: Uri, + newUri: Uri, + options?: { overwrite?: boolean; ignoreIfExists?: boolean } + ): void { + const key = oldUri.toString(); + if (!this._edits.has(key)) { + this._edits.set(key, []); + } + this._edits.get(key)!.push({ type: 'rename', oldUri, newUri, options }); + } + + // Internal method to get edits for applying + _getEdits(): Map { + return this._edits; + } + + get size(): number { + return this._edits.size; + } +} + +// ===== FileSystem Mock ===== + +export interface FileSystem { + readFile(uri: Uri): Thenable; + writeFile(uri: Uri, content: Uint8Array): Thenable; + delete(uri: Uri, options?: { recursive?: boolean }): Thenable; + stat( + uri: Uri + ): Thenable<{ type: number; size: number; mtime: number; ctime: number }>; + readDirectory(uri: Uri): Thenable<[string, number][]>; + createDirectory(uri: Uri): Thenable; + copy( + source: Uri, + target: Uri, + options?: { overwrite?: boolean } + ): Thenable; + rename( + source: Uri, + target: Uri, + options?: { overwrite?: boolean } + ): Thenable; +} + +class MockFileSystem implements FileSystem { + async readFile(uri: Uri): Promise { + const content = await fs.promises.readFile(uri.fsPath); + return new Uint8Array(content); + } + + async writeFile(uri: Uri, content: Uint8Array): Promise { + // Ensure directory exists + const dir = path.dirname(uri.fsPath); + await fs.promises.mkdir(dir, { recursive: true }); + await fs.promises.writeFile(uri.fsPath, content); + } + + async delete(uri: Uri, options?: { recursive?: boolean }): Promise { + if (options?.recursive) { + // Use rmdir with recursive option for older Node.js versions + try { + await fs.promises.rmdir(uri.fsPath, { recursive: true }); + } catch { + // Fallback for very old Node.js versions + await fs.promises.unlink(uri.fsPath); + } + } else { + await fs.promises.unlink(uri.fsPath); + } + } + + async stat( + uri: Uri + ): Promise<{ type: number; size: number; mtime: number; ctime: number }> { + const stats = await fs.promises.stat(uri.fsPath); + return { + type: stats.isFile() ? 1 : stats.isDirectory() ? 2 : 0, + size: stats.size, + mtime: stats.mtime.getTime(), + ctime: stats.ctime.getTime(), + }; + } + + async readDirectory(uri: Uri): Promise<[string, number][]> { + const entries = await fs.promises.readdir(uri.fsPath, { + withFileTypes: true, + }); + return entries.map(entry => [ + entry.name, + entry.isFile() ? 1 : entry.isDirectory() ? 2 : 0, + ]); + } + + async createDirectory(uri: Uri): Promise { + await fs.promises.mkdir(uri.fsPath, { recursive: true }); + } + + async copy( + source: Uri, + target: Uri, + options?: { overwrite?: boolean } + ): Promise { + await fs.promises.copyFile(source.fsPath, target.fsPath); + } + + async rename( + source: Uri, + target: Uri, + options?: { overwrite?: boolean } + ): Promise { + await fs.promises.rename(source.fsPath, target.fsPath); + } +} + +// ===== Workspace Folder ===== + +export interface WorkspaceFolder { + readonly uri: Uri; + readonly name: string; + readonly index: number; +} + +// ===== Extension Context ===== + +export interface ExtensionContext { + subscriptions: Disposable[]; + workspaceState: any; + globalState: any; + extensionPath: string; + extensionUri: Uri; + storageUri: Uri | undefined; + globalStorageUri: Uri; + logUri: Uri; + secrets: any; + environmentVariableCollection: any; + asAbsolutePath(relativePath: string): string; + storagePath: string | undefined; + globalStoragePath: string; + logPath: string; + extensionMode: number; + extension: any; +} + +function createMockExtensionContext(): ExtensionContext { + return { + subscriptions: [], + workspaceState: { + get: () => undefined, + update: () => Promise.resolve(), + }, + globalState: { + get: () => undefined, + update: () => Promise.resolve(), + }, + extensionPath: '/mock/extension/path', + extensionUri: createVSCodeUri(URI.parse('file:///mock/extension/path')), + storageUri: undefined, + globalStorageUri: createVSCodeUri(URI.parse('file:///mock/global/storage')), + logUri: createVSCodeUri(URI.parse('file:///mock/logs')), + secrets: { + get: () => Promise.resolve(undefined), + store: () => Promise.resolve(), + delete: () => Promise.resolve(), + }, + environmentVariableCollection: { + clear: () => {}, + get: () => undefined, + set: () => {}, + delete: () => {}, + }, + asAbsolutePath: (relativePath: string) => + path.join('/mock/extension/path', relativePath), + storagePath: '/mock/storage', + globalStoragePath: '/mock/global/storage', + logPath: '/mock/logs', + extensionMode: 1, + extension: { + id: 'foam.foam-vscode', + packageJSON: {}, + }, + }; +} + +// ===== Foam Commands Lazy Initialization ===== + +class TestFoam { + private static instance: Foam | null = null; + + static async getInstance(): Promise { + if (!TestFoam.instance) { + TestFoam.instance = await TestFoam.bootstrap(); + } + return TestFoam.instance; + } + + static async bootstrap(): Promise { + const workspaceFolder = mockState.workspaceFolders[0]; + if (!workspaceFolder) { + throw new Error('No workspace folder available for mock Foam'); + } + + // Create real file system implementations + const listFiles = async (): Promise => { + // Recursively find all markdown files in the workspace + const findMarkdownFiles = async (dir: string): Promise => { + const files: URI[] = []; + try { + const entries = await fs.promises.readdir(dir, { + withFileTypes: true, + }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + const subFiles = await findMarkdownFiles(fullPath); + files.push(...subFiles); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + files.push(URI.file(fullPath)); + } + } + } catch (error) { + // Ignore errors accessing directories + } + + return files; + }; + + return findMarkdownFiles(workspaceFolder.uri.fsPath); + }; + + const readFile = async (uri: URI): Promise => { + try { + return await fs.promises.readFile(uri.toFsPath(), 'utf8'); + } catch (error) { + Logger.debug(`Failed to read file ${uri.toString()}: ${error}`); + return ''; + } + }; + + // Create services + const dataStore = new GenericDataStore(listFiles, readFile); + const parser = createMarkdownParser(); + const matcher = new AlwaysIncludeMatcher(); // Accept all markdown files + + // Create resource providers + const providers = [new MarkdownResourceProvider(dataStore, parser)]; + + // Use the bootstrap function without file watcher (simpler for tests) + const foam = await bootstrap( + matcher, + undefined, + dataStore, + parser, + providers, + '.md' + ); + + Logger.info('Mock Foam instance created (manual reload for tests)'); + return foam; + } + + static async reloadFoamWorkspace(): Promise { + // Simple reload: clear workspace and reload all files + TestFoam.instance.workspace.clear(); + + // Re-read all markdown files from the filesystem + const files = await TestFoam.instance.services.dataStore.list(); + for (const file of files) { + await TestFoam.instance.workspace.fetchAndSet(file); + } + + TestFoam.instance.graph.update(); + TestFoam.instance.tags.update(); + + Logger.debug(`Reloaded workspace with ${files.length} files`); + } + + static dispose() { + if (TestFoam.instance) { + try { + TestFoam.instance.dispose(); + } catch (error) { + // Ignore disposal errors + } + TestFoam.instance = null; + } + } +} + +async function initializeFoamCommands(foam: Foam): Promise { + const mockContext = createMockExtensionContext(); + + const foamPromise = Promise.resolve(foam); + // Initialize all command modules + // Commands that need Foam instance + await foamCommands.createNote(mockContext, foamPromise); + await foamCommands.janitorCommand(mockContext, foamPromise); + await foamCommands.openRandomNoteCommand(mockContext, foamPromise); + await foamCommands.openResource(mockContext, foamPromise); + await foamCommands.updateGraphCommand(mockContext, foamPromise); + await foamCommands.updateWikilinksCommand(mockContext, foamPromise); + await foamCommands.generateStandaloneNote(mockContext, foamPromise); + await foamCommands.openDailyNoteForDateCommand(mockContext, foamPromise); + + // Commands that only need context + await foamCommands.copyWithoutBracketsCommand(mockContext); + await foamCommands.createFromTemplateCommand(mockContext); + await foamCommands.createNewTemplate(mockContext); + await foamCommands.openDailyNoteCommand(mockContext); + await foamCommands.openDatedNote(mockContext); + + Logger.info('Foam commands initialized successfully in mock environment'); +} + +// ===== VS Code Namespaces ===== + +// Global state +const mockState = { + activeTextEditor: undefined as TextEditor | undefined, + visibleTextEditors: [] as TextEditor[], + workspaceFolders: [] as WorkspaceFolder[], + commands: new Map any>(), + fileSystem: new MockFileSystem(), + configuration: new MockWorkspaceConfiguration(), +}; + +// Window namespace +export const window = { + get activeTextEditor(): TextEditor | undefined { + return mockState.activeTextEditor; + }, + + set activeTextEditor(editor: TextEditor | undefined) { + mockState.activeTextEditor = editor; + }, + + get visibleTextEditors(): TextEditor[] { + return mockState.visibleTextEditors; + }, + + async showInputBox(options?: { + value?: string; + prompt?: string; + placeHolder?: string; + password?: boolean; + validateInput?: (value: string) => string | undefined; + }): Promise { + // This will be mocked in tests + return undefined; + }, + + async showQuickPick(items: any[], options?: any): Promise { + throw new Error( + 'showQuickPick not implemented - should be mocked in tests' + ); + }, + + async showTextDocument( + documentOrUri: TextDocument | Uri, + options?: { + viewColumn?: ViewColumn; + preserveFocus?: boolean; + preview?: boolean; + selection?: Range; + } + ): Promise { + let document: TextDocument; + + if ('uri' in documentOrUri) { + document = documentOrUri; + } else { + document = await workspace.openTextDocument(documentOrUri); + } + + const editor = new MockTextEditor(document, options?.viewColumn); + + if (options?.selection) { + editor.selection = new Selection( + options.selection.start, + options.selection.end + ); + editor.selections = [editor.selection]; + } + + mockState.activeTextEditor = editor; + + if (!mockState.visibleTextEditors.includes(editor)) { + mockState.visibleTextEditors.push(editor); + } + + return editor; + }, + + async showInformationMessage( + message: string, + ...items: string[] + ): Promise { + // Mock implementation - do nothing + return undefined; + }, + + async showWarningMessage( + message: string, + ...items: string[] + ): Promise { + // Mock implementation - do nothing + return undefined; + }, + + async showErrorMessage( + message: string, + ...items: string[] + ): Promise { + // Mock implementation - do nothing + return undefined; + }, +}; + +// Workspace namespace +export const workspace = { + get workspaceFolders(): WorkspaceFolder[] | undefined { + return mockState.workspaceFolders.length > 0 + ? mockState.workspaceFolders + : undefined; + }, + + get fs(): FileSystem { + return mockState.fileSystem; + }, + + getConfiguration(section?: string): WorkspaceConfiguration { + if (section) { + // Return a scoped configuration for the specific section + const scopedConfig = new MockWorkspaceConfiguration(); + // Copy relevant config values that start with the section + for (const [key, value] of (mockState.configuration as any)._config) { + if (key.startsWith(`${section}.`)) { + const sectionKey = key.substring(section.length + 1); + (scopedConfig as any)._config.set(sectionKey, value); + } + } + return scopedConfig; + } + return mockState.configuration; + }, + + async findFiles( + include: string, + exclude?: string, + maxResults?: number + ): Promise { + // Simple implementation that recursively finds files + const workspaceFolder = mockState.workspaceFolders[0]; + + if (!workspaceFolder) { + return []; + } + + const findFilesRecursive = async (dir: string): Promise => { + const files: string[] = []; + try { + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + const relativePath = path.relative( + workspaceFolder.uri.fsPath, + fullPath + ); + + if (entry.isDirectory()) { + const subFiles = await findFilesRecursive(fullPath); + files.push(...subFiles); + } else if (entry.isFile()) { + // Check if file matches include pattern + if (micromatch.isMatch(relativePath, include)) { + // Check if file matches exclude pattern + if (!exclude || !micromatch.isMatch(relativePath, exclude)) { + files.push(fullPath); + } + } + } + } + } catch (error) { + // Ignore errors accessing directories + } + + return files; + }; + + try { + const files = await findFilesRecursive(workspaceFolder.uri.fsPath); + + let result = files.map(file => createVSCodeUri(URI.file(file))); + + if (maxResults && result.length > maxResults) { + result = result.slice(0, maxResults); + } + + return result; + } catch (error) { + return []; + } + }, + + getWorkspaceFolder(uri: Uri): WorkspaceFolder | undefined { + const workspaceFolder = mockState.workspaceFolders.find(folder => + uri.fsPath.startsWith(folder.uri.fsPath) + ); + return workspaceFolder; + }, + + onWillSaveTextDocument(listener: (e: any) => void): Disposable { + // Mock event listener for document save events + return { + dispose: () => { + // No-op + }, + }; + }, + + async openTextDocument( + uriOrFileNameOrOptions: + | Uri + | string + | { language?: string; content?: string } + ): Promise { + let uri: Uri; + let content: string | undefined; + + if (typeof uriOrFileNameOrOptions === 'string') { + uri = createVSCodeUri(URI.file(uriOrFileNameOrOptions)); + } else if ('scheme' in uriOrFileNameOrOptions) { + uri = uriOrFileNameOrOptions; + } else { + // Create untitled document + uri = createVSCodeUri(URI.parse(`untitled:Untitled-${Date.now()}`)); + content = uriOrFileNameOrOptions.content || ''; + } + + // Always create a fresh document to ensure we get the latest content + const document = new MockTextDocument(uri, content); + return document; + }, + + async applyEdit(edit: WorkspaceEdit): Promise { + try { + for (const [uriString, edits] of edit._getEdits()) { + const uri = createVSCodeUri(URI.parse(uriString)); + const document = await workspace.openTextDocument(uri); + + if (document instanceof MockTextDocument) { + let content = document.getText(); + + // Apply edits in reverse order to maintain positions + const sortedEdits = edits.sort((a, b) => { + if (a.type === 'replace' && b.type === 'replace') { + return Position.compareTo(b.range.start, a.range.start); + } + // Add more sophisticated sorting for other edit types + return 0; + }); + + for (const edit of sortedEdits) { + if (edit.type === 'replace') { + content = TextEdit.apply(content, { + newText: edit.newText, + range: edit.range, + }); + } else if (edit.type === 'rename') { + // Handle file rename by physically moving the file + await fs.promises.rename(edit.oldUri.fsPath, edit.newUri.fsPath); + } + // Handle other edit types as needed + } + + document._updateContent(content); + } + } + + return true; + } catch (e) { + Logger.error('vscode-mock: Failed to apply edit', e); + return false; + } + }, + + asRelativePath( + pathOrUri: string | Uri, + includeWorkspaceFolder?: boolean + ): string { + const workspaceFolder = mockState.workspaceFolders[0]; + if (!workspaceFolder) { + return typeof pathOrUri === 'string' ? pathOrUri : pathOrUri.fsPath; + } + + const fsPath = typeof pathOrUri === 'string' ? pathOrUri : pathOrUri.fsPath; + const relativePath = path.relative(workspaceFolder.uri.fsPath, fsPath); + + if (includeWorkspaceFolder) { + return `${workspaceFolder.name}/${relativePath}`; + } + + return relativePath; + }, + + get isTrusted(): boolean { + // Mock workspace as trusted for testing + return true; + }, +}; + +// Commands namespace +export const commands = { + registerCommand( + command: string, + callback: (...args: any[]) => any + ): { dispose(): void } { + mockState.commands.set(command, callback); + return { + dispose() { + mockState.commands.delete(command); + }, + }; + }, + + async executeCommand( + command: string, + ...args: any[] + ): Promise { + // Auto-initialize Foam commands if this is a foam-vscode command + if (command.startsWith('foam-vscode.')) { + await initializeFoamCommands(await TestFoam.getInstance()); + } + + const handler = mockState.commands.get(command); + if (!handler) { + throw new Error(`Command '${command}' not found`); + } + + return handler(...args); + }, +}; + +// Languages namespace +export const languages = { + registerCodeLensProvider(selector: any, provider: any): Disposable { + // Mock code lens provider registration + return { + dispose: () => { + // No-op + }, + }; + }, +}; + +// Env namespace +export const env = { + __mockClipboard: '', + clipboard: { + async writeText(value: string): Promise { + env.__mockClipboard = value; + }, + + async readText(): Promise { + return env.__mockClipboard || ''; + }, + }, + + // Other common env properties + appName: 'Visual Studio Code', + appRoot: '/mock/vscode', + language: 'en', + sessionId: 'mock-session', + machineId: 'mock-machine', +}; + +// ===== Initialization Helper ===== + +export function initializeWorkspace(workspaceRoot: string): void { + const uri = createVSCodeUri(URI.file(workspaceRoot)); + const folder: WorkspaceFolder = { + uri, + name: path.basename(workspaceRoot), + index: 0, + }; + + mockState.workspaceFolders = [folder]; +} + +// ===== Utility Functions ===== + +// Clean up state for tests +export function resetMockState(): void { + // Clean up existing Foam instance + TestFoam.dispose(); + mockState.activeTextEditor = undefined; + mockState.visibleTextEditors = []; + mockState.workspaceFolders = []; + mockState.commands.clear(); + mockState.configuration = new MockWorkspaceConfiguration(); + + // Create a default workspace folder for tests + const defaultWorkspaceRoot = path.join( + os.tmpdir(), + 'foam-mock-workspace-' + randomString(3) + ); + fs.mkdirSync(defaultWorkspaceRoot, { recursive: true }); + + initializeWorkspace(defaultWorkspaceRoot); + + // Register built-in VS Code commands + commands.registerCommand('workbench.action.closeAllEditors', () => { + // Reset active editor to simulate closing all editors + (window as any).activeTextEditor = undefined; + return Promise.resolve(); + }); + + commands.registerCommand('vscode.open', async uri => { + // Mock opening a file - just show it in editor + return window.showTextDocument(uri); + }); + + commands.registerCommand('setContext', (key: string, value: any) => { + // Mock command for setting VS Code context + return Promise.resolve(); + }); +} + +// Initialize the mock state when the module is loaded +resetMockState(); + +// ===== Force Cleanup for Test Files ===== + +export async function forceCleanup(): Promise { + // Clean up existing Foam instance + TestFoam.dispose(); + + // Clear all registered commands + mockState.commands.clear(); + + // Clear all event listeners by resetting emitters + mockState.activeTextEditor = undefined; + mockState.visibleTextEditors = []; + + // Close any open file handles by clearing the file system + mockState.fileSystem = new MockFileSystem(); + + // Clear configuration + mockState.configuration = new MockWorkspaceConfiguration(); + + // Force garbage collection + if (global.gc) { + global.gc(); + } + + // Wait for any pending file system operations to complete + await new Promise(resolve => setTimeout(resolve, 10)); +}