|
| 1 | +# Spec: Infrastructure Abstraction Seams for Testability (#212) |
| 2 | + |
| 3 | +## Goal |
| 4 | + |
| 5 | +Introduce an `IFileSystem` interface (free of `vscode.*` types) that decouples `vscode.workspace.fs` from core domain logic, enabling pure-Node unit tests for file creation and directory walking without the VS Code Extension Host. |
| 6 | + |
| 7 | +## Why Now |
| 8 | + |
| 9 | +The test suite is entirely extension-host-bound: every test spins up a real VS Code process (~30 s suite run) because core logic calls `vscode.workspace.fs` directly. Introducing a seam here unblocks fast TDD cycles (< 5 s plain-Node) for the most I/O-heavy domain classes (`Writer`, `ScanEntries`, `fileExists`). This is the smallest architectural step with the highest return on test speed. |
| 10 | + |
| 11 | +## Existing vs. New |
| 12 | + |
| 13 | +The existing `IWriter`, `IReader`, `IInject`, `IDialogues` interfaces in `src/model/interfaces.ts` already serve as service-level seams. However they are insufficient for headless testing because: |
| 14 | + |
| 15 | +- `Writer.createSaveLoadTextDocument` calls `vscode.workspace.fs.writeFile` and `vscode.workspace.openTextDocument` directly (inconsistently — `Reader` already delegates the `openDocument` step to `IDialogues`). |
| 16 | +- `fileExists()` in `src/util/fs-exists.ts` calls `vscode.workspace.fs.stat` directly. |
| 17 | +- `ScanEntries.walkDir()` calls `vscode.workspace.fs.readDirectory` and `.stat` directly. |
| 18 | + |
| 19 | +The issue also mentions a `WorkspaceUI` interface. `IDialogues` in `src/model/interfaces.ts` already covers exactly that surface (`openDocument`, `showDocument`, `showError`, `getUserInput`). No new interface needed; the issue's `WorkspaceUI` name maps to `IDialogues`. |
| 20 | + |
| 21 | +## Amendment (post-review) |
| 22 | + |
| 23 | +Initial spec used `vscode.Uri` and `vscode.FileStat`/`vscode.FileType` in the `IFileSystem` interface. This was correctly identified as a type leak: `vscode.Uri` is a runtime class from the Extension Host, so any file importing the interface would still require the extension host for plain-Node tests. |
| 24 | + |
| 25 | +Changes from the initial draft: |
| 26 | +1. `IFileSystem` now uses `string` paths (URI string representation, e.g., `file:///…`) and defines its own `JFileStat` / `JFileType` in the model — zero `vscode.*` imports in the interface. |
| 27 | +2. `Writer.createSaveLoadTextDocument` delegates `openTextDocument` to `IDialogues.openDocument` (aligning with `Reader`'s pattern) — eliminates the remaining direct `vscode` call from `Writer`. |
| 28 | +3. `IWorkspaceEditor` / `WorkspaceEdit` seam is **out of scope** — `Inject.ts` operates on already-open editor buffers, which is editor I/O, not filesystem I/O. Different seam, separate issue. |
| 29 | +4. Removing all `vscode.*` types from `IWriter`/`IReader`/`IInject`/`IDialogues` (the `TextDocument`, `Position`, `TextEditor` surface) is Phase 2.1 work — deferred. `IFileSystem` itself is fully clean. |
| 30 | + |
| 31 | +## In Scope |
| 32 | + |
| 33 | +1. **Define `JFileType` and `JFileStat`** in `src/model/fs.ts` (new file, no `vscode` import): |
| 34 | + ```ts |
| 35 | + export enum JFileType { Unknown = 0, File = 1, Directory = 2, SymbolicLink = 64 } |
| 36 | + export interface JFileStat { type: JFileType; ctime: number; mtime: number; size: number; } |
| 37 | + ``` |
| 38 | + |
| 39 | +2. **Define `IFileSystem` interface** in `src/model/interfaces.ts` — all paths are `string` (URI string form): |
| 40 | + - `stat(path: string): Promise<JFileStat>` |
| 41 | + - `readFile(path: string): Promise<Uint8Array>` |
| 42 | + - `writeFile(path: string, content: Uint8Array): Promise<void>` |
| 43 | + - `readDirectory(path: string): Promise<[string, JFileType][]>` |
| 44 | + - `createDirectory(path: string): Promise<void>` |
| 45 | + - `delete(path: string, options?: { recursive?: boolean }): Promise<void>` |
| 46 | + |
| 47 | +3. **Provide `VscodeFileSystem`** (`src/vscode/vscode-fs.ts`) — converts `string → vscode.Uri.file(path)` for every call, maps `vscode.FileType ↔ JFileType` and `vscode.FileStat → JFileStat`. Production implementation. Zero vscode imports visible to callers. Note: `Uri.file` is correct because callers pass OS paths (not URI strings); `extensionKind: ["workspace"]` ensures the extension runs on the remote host so OS paths are remote-local. |
| 48 | + |
| 49 | +4. **Thread `IFileSystem` into `Ctrl`** (`src/util/controller.ts`): add `fs: IFileSystem` getter. `Startup.initServices` constructs `new VscodeFileSystem()`. |
| 50 | + |
| 51 | +5. **Replace direct `vscode.workspace.fs` calls**: |
| 52 | + - `src/util/fs-exists.ts` — change to `fileExists(fs: IFileSystem, path: string): Promise<boolean>`. Catch `{ code: 'FileNotFound' }` instead of `vscode.FileSystemError`. |
| 53 | + - `src/journal/writer.ts` — inject `IFileSystem` (for `writeFile`) and `IDialogues` (for `openDocument`) via constructor, removing the two direct `vscode` calls. |
| 54 | + - `src/features/entries/scan-entries.ts` — inject `IFileSystem`, replace `.stat` and `.readDirectory` calls; map `JFileType.Directory` instead of `vscode.FileType.Directory`. |
| 55 | + |
| 56 | +6. **Ship `InMemoryFileSystem` test double** (`src/test/in-memory-fs.ts`) — `Map<string, Uint8Array>` backing store + `Map<string, [string, JFileType][]>` for directory entries. Throws `{ code: 'FileNotFound' }` for missing paths. Zero vscode imports. |
| 57 | + |
| 58 | +7. **Add pure-Node unit tests** (no extension host, run via `node` / mocha-direct): |
| 59 | + - `src/test/suite/writer-unit.test.ts` — verifies `createSaveLoadTextDocument` writes correct bytes at the correct path, with stubbed `IDialogues.openDocument`. |
| 60 | + - `src/test/suite/scan-entries-unit.test.ts` — verifies `walkDir` traversal against an in-memory directory tree. |
| 61 | + |
| 62 | +## Out of Scope |
| 63 | + |
| 64 | +- Removing `vscode.TextDocument` from `IWriter`/`IReader` return types — Phase 2.1. |
| 65 | +- Introducing `JournalDocument` type — Phase 2.1. |
| 66 | +- Renaming `IDialogues` to `WorkspaceUI` — no functional value now. |
| 67 | +- `IWorkspaceEditor` / `WorkspaceEdit` seam for `Inject.ts` — editor-buffer I/O, not filesystem I/O; separate seam, separate issue. |
| 68 | +- Full constructor-injection removal of `Ctrl` — Phase 2.1, issue #208. |
| 69 | + |
| 70 | +## Acceptance Criteria |
| 71 | + |
| 72 | +1. `JFileType`, `JFileStat`, `IFileSystem` defined in `src/model/` with zero `vscode` imports (grep-verifiable). |
| 73 | +2. `VscodeFileSystem` passes all current integration tests unchanged (no behaviour change). |
| 74 | +3. `InMemoryFileSystem` in `src/test/in-memory-fs.ts` — zero vscode imports; used by both new test files. |
| 75 | +4. `npm test -- --grep "writer-unit|scan-entries-unit"` runs plain-Node (no Extension Host), completing < 5 s. |
| 76 | +5. `npm run compile-tests` and `npm test` pass with zero new errors. |
| 77 | +6. No raw `vscode.workspace.fs` call remains in `Writer`, `fileExists`, or `ScanEntries` (grep check). |
| 78 | +7. `Writer.createSaveLoadTextDocument` no longer calls `vscode.workspace.openTextDocument` directly. |
| 79 | + |
| 80 | +## Entities / Contracts |
| 81 | + |
| 82 | +### `JFileType` and `JFileStat` (new, `src/model/fs.ts`) |
| 83 | + |
| 84 | +```ts |
| 85 | +export enum JFileType { Unknown = 0, File = 1, Directory = 2, SymbolicLink = 64 } |
| 86 | +export interface JFileStat { |
| 87 | + type: JFileType; |
| 88 | + ctime: number; |
| 89 | + mtime: number; |
| 90 | + size: number; |
| 91 | +} |
| 92 | +``` |
| 93 | + |
| 94 | +### `IFileSystem` (new, `src/model/interfaces.ts`) |
| 95 | + |
| 96 | +```ts |
| 97 | +export interface IFileSystem { |
| 98 | + stat(path: string): Promise<JFileStat>; |
| 99 | + readFile(path: string): Promise<Uint8Array>; |
| 100 | + writeFile(path: string, content: Uint8Array): Promise<void>; |
| 101 | + readDirectory(path: string): Promise<[string, JFileType][]>; |
| 102 | + createDirectory(path: string): Promise<void>; |
| 103 | + delete(path: string, options?: { recursive?: boolean }): Promise<void>; |
| 104 | +} |
| 105 | +``` |
| 106 | + |
| 107 | +### `VscodeFileSystem` (new, `src/vscode/vscode-fs.ts`) |
| 108 | + |
| 109 | +Converts `string → vscode.Uri.file(path)`. Maps `vscode.FileType ↔ JFileType` and `vscode.FileStat → JFileStat`. No logic beyond mapping; purely a type-boundary adapter. |
| 110 | + |
| 111 | +### `fileExists(fs: IFileSystem, path: string): Promise<boolean>` |
| 112 | + |
| 113 | +Calls `fs.stat(path)`; catches `{ code: 'FileNotFound' }` → `false`; rethrows others. No `vscode.FileSystemError` reference. |
| 114 | + |
| 115 | +### `Writer` constructor change |
| 116 | + |
| 117 | +```ts |
| 118 | +constructor( |
| 119 | + private config: IConfiguration, |
| 120 | + private logger: ILogger, |
| 121 | + private inject: IInject, |
| 122 | + private fs: IFileSystem, // new |
| 123 | + private ui: IDialogues, // new (was absent; openDocument delegated here) |
| 124 | +) |
| 125 | +``` |
| 126 | + |
| 127 | +### `ScanEntries` constructor change |
| 128 | + |
| 129 | +```ts |
| 130 | +constructor( |
| 131 | + private config: IConfiguration, |
| 132 | + private logger: ILogger, |
| 133 | + private fs: IFileSystem, // new |
| 134 | +) |
| 135 | +``` |
| 136 | + |
| 137 | +### `JournalController` extension |
| 138 | + |
| 139 | +```ts |
| 140 | +export interface JournalController { |
| 141 | + // ... existing ... |
| 142 | + fs: IFileSystem; // new |
| 143 | +} |
| 144 | +``` |
| 145 | + |
| 146 | +## Constraints |
| 147 | + |
| 148 | +- `VscodeFileSystem` uses `vscode.Uri.file(path)` — callers pass OS paths; `extensionKind: ["workspace"]` places the extension on the remote host so OS paths resolve correctly there. |
| 149 | +- `InMemoryFileSystem` lives under `src/test/`, never in the extension bundle. |
| 150 | +- `{ code: 'FileNotFound' }` is the error shape used by `InMemoryFileSystem`; `VscodeFileSystem` re-throws `vscode.FileSystemError` as-is (its `.code` is already `'FileNotFound'`), so `fileExists` catch logic is stable. |
| 151 | +- Enum values of `JFileType` mirror `vscode.FileType` numeric values to simplify the mapping in `VscodeFileSystem`. |
| 152 | + |
| 153 | +## Open Questions |
| 154 | + |
| 155 | +None. |
| 156 | + |
| 157 | +## Related Issues |
| 158 | + |
| 159 | +- Blocks: none |
| 160 | +- Blocked by: none |
| 161 | +- Related: #208 (Phase 2.1 DI; this spec is a compatible preparatory step) |
0 commit comments