|
| 1 | +# Plan: Decouple Tests from Global VS Code Configuration State — #202 |
| 2 | + |
| 3 | +## Reference spec |
| 4 | +[docs/specs/2026-05-19-202-decouple-test-config.md](../specs/2026-05-19-202-decouple-test-config.md) |
| 5 | + |
| 6 | +## Approach |
| 7 | +Introduce a thin `IWorkspaceConfigReader` interface (single `get<T>` overload pair) so `Configuration` and `Ctrl` can be constructed with a plain object instead of `vscode.WorkspaceConfiguration`. Add `FakeWorkspaceConfig` test double. Refactor 14 test files to build `Ctrl` from `FakeWorkspaceConfig({…})` and delete all `original*` state variables + `afterEach` blocks that only reset config state. |
| 8 | + |
| 9 | +Trade-off: `FakeWorkspaceConfig` is a flat dictionary lookup. `Configuration` exclusively reads top-level keys (confirmed: `base`, `locale`, `ext`, `scopes`, `patterns`, `templates`, `weeklySync`, `navigation.mode`, `dev`, `openInNewEditorGroup`, `syntax-highlighting`, `entryGranularity`, `tpl-*`) — no nested path reads — so flat lookup is safe and no special path parser is needed. |
| 10 | + |
| 11 | +## Steps |
| 12 | + |
| 13 | +### 1 — Add `IWorkspaceConfigReader` to `src/model/interfaces.ts` |
| 14 | + |
| 15 | +Add after the existing interface declarations: |
| 16 | + |
| 17 | +```typescript |
| 18 | +export interface IWorkspaceConfigReader { |
| 19 | + get<T>(section: string): T | undefined; |
| 20 | + get<T>(section: string, defaultValue: T): T; |
| 21 | +} |
| 22 | +``` |
| 23 | + |
| 24 | +**Why first:** `Configuration` and `Ctrl` import from `../model`; the interface must exist before their constructors are changed. `vscode.WorkspaceConfiguration` satisfies this structurally — TypeScript confirms at compile time that no cast is needed in `Startup.ts`. |
| 25 | + |
| 26 | +### 2 — Update `Configuration` constructor (`src/vscode/conf.ts`) |
| 27 | + |
| 28 | +Change constructor signature: |
| 29 | +```typescript |
| 30 | +// Before |
| 31 | +constructor(vscodeConfig: vscode.WorkspaceConfiguration) { |
| 32 | + this._config = vscodeConfig; |
| 33 | +} |
| 34 | +// After |
| 35 | +import { IWorkspaceConfigReader } from '../model'; |
| 36 | +constructor(private readonly config: IWorkspaceConfigReader) {} |
| 37 | +``` |
| 38 | + |
| 39 | +Note: the field is already named `config` throughout `conf.ts` — only the type annotation changes. Remove `vscode.WorkspaceConfiguration` from the constructor parameter type; keep all `this.config.get<…>(…)` call sites unchanged. |
| 40 | + |
| 41 | +**Why:** This is the primary seam. After this change, `Configuration` no longer depends on a VS Code API type. |
| 42 | + |
| 43 | +### 3 — Update `Ctrl` constructor (`src/util/controller.ts`) |
| 44 | + |
| 45 | +```typescript |
| 46 | +// Before |
| 47 | +constructor(vscodeConfig: vscode.WorkspaceConfiguration) { |
| 48 | + this._config = new Configuration(vscodeConfig); |
| 49 | +} |
| 50 | +// After |
| 51 | +import { IWorkspaceConfigReader } from '../model'; |
| 52 | +constructor(configSource: IWorkspaceConfigReader) { |
| 53 | + this._config = new Configuration(configSource); |
| 54 | +} |
| 55 | +``` |
| 56 | + |
| 57 | +**Why:** `Ctrl` is the entry point tests use. Changing its constructor to `IWorkspaceConfigReader` lets tests pass `new FakeWorkspaceConfig(…)` directly. |
| 58 | + |
| 59 | +### 4 — Verify `npm run compile` (structural typing gate) |
| 60 | + |
| 61 | +```bash |
| 62 | +npm run compile |
| 63 | +``` |
| 64 | + |
| 65 | +Must exit 0. If `Startup.ts` passes `vscode.workspace.getConfiguration('journal')` to `new Ctrl(…)`, TypeScript will silently accept it — `vscode.WorkspaceConfiguration` structurally satisfies `IWorkspaceConfigReader`. Zero changes expected in `Startup.ts`. Any compile error at this step means `IWorkspaceConfigReader` is missing a method that `Configuration` calls on `this.config`. |
| 66 | + |
| 67 | +### 5 — Create `FakeWorkspaceConfig` (`src/test/fake-workspace-config.ts`) |
| 68 | + |
| 69 | +```typescript |
| 70 | +import { IWorkspaceConfigReader } from '../model'; |
| 71 | + |
| 72 | +export class FakeWorkspaceConfig implements IWorkspaceConfigReader { |
| 73 | + constructor(private readonly settings: Record<string, unknown> = {}) {} |
| 74 | + |
| 75 | + get<T>(section: string, defaultValue?: T): T | undefined { |
| 76 | + return (section in this.settings |
| 77 | + ? this.settings[section] |
| 78 | + : defaultValue) as T | undefined; |
| 79 | + } |
| 80 | +} |
| 81 | +``` |
| 82 | + |
| 83 | +**Why:** Single implementation used by all 14 test files. Constructor accepts a plain object — tests declare their needed settings inline with no async I/O. |
| 84 | + |
| 85 | +### 6 — Refactor all 14 test files |
| 86 | + |
| 87 | +For each file listed below, apply the same mechanical change: |
| 88 | + |
| 89 | +**Pattern A — module-level `beforeEach` with single `ctrl` per suite:** |
| 90 | +```typescript |
| 91 | +// Before (in beforeEach / before) |
| 92 | +const config = vscode.workspace.getConfiguration('journal'); |
| 93 | +await config.update('base', tmpBase, vscode.ConfigurationTarget.Workspace); |
| 94 | +const refreshed = vscode.workspace.getConfiguration('journal'); |
| 95 | +ctrl = new J.Util.Ctrl(refreshed); |
| 96 | + |
| 97 | +// After |
| 98 | +ctrl = new J.Util.Ctrl(new FakeWorkspaceConfig({ base: tmpBase })); |
| 99 | +``` |
| 100 | + |
| 101 | +**Pattern B — per-describe `beforeEach` with `original*` save/restore:** |
| 102 | +```typescript |
| 103 | +// Before |
| 104 | +let originalBase: string | undefined; |
| 105 | +beforeEach(async () => { |
| 106 | + const config = vscode.workspace.getConfiguration('journal'); |
| 107 | + originalBase = config.get<string>('base'); |
| 108 | + await config.update('base', tmpBase, vscode.ConfigurationTarget.Workspace); |
| 109 | + ctrl = new J.Util.Ctrl(vscode.workspace.getConfiguration('journal')); |
| 110 | +}); |
| 111 | +afterEach(async () => { |
| 112 | + await config.update('base', originalBase, vscode.ConfigurationTarget.Workspace); |
| 113 | +}); |
| 114 | + |
| 115 | +// After |
| 116 | +beforeEach(() => { |
| 117 | + ctrl = new J.Util.Ctrl(new FakeWorkspaceConfig({ base: tmpBase })); |
| 118 | +}); |
| 119 | +// afterEach DELETED (no global state to restore) |
| 120 | +// originalBase variable DELETED |
| 121 | +``` |
| 122 | + |
| 123 | +**Caution for `commands-prev-next.test.ts`:** some `afterEach` blocks also restore `vscode.window.showInformationMessage`. Keep those restores — only remove the `config.update` lines and the `original*` config variables. |
| 124 | + |
| 125 | +**Files to refactor:** |
| 126 | +1. `commands-inject.test.ts` |
| 127 | +2. `week-input.test.ts` |
| 128 | +3. `notes-sync.test.ts` |
| 129 | +4. `issue-168-entry-granularity.test.ts` |
| 130 | +5. `commands-entry.test.ts` |
| 131 | +6. `phase1-regression.test.ts` |
| 132 | +7. `commands-prev-next.test.ts` |
| 133 | +8. `read-templates.test.ts` |
| 134 | +9. `issue-185-weekly-sync.test.ts` |
| 135 | +10. `input.test.ts` |
| 136 | +11. `commands-note.test.ts` |
| 137 | +12. `issue-51-remote-create.test.ts` |
| 138 | +13. `commands-weekly.test.ts` |
| 139 | +14. `scan-entries-cache.test.ts` |
| 140 | + |
| 141 | +For each file, also remove any `import … ConfigurationTarget` from the VS Code import once all uses are gone. |
| 142 | + |
| 143 | +### 7 — Verify `npm run compile-tests` and `npm test` |
| 144 | + |
| 145 | +```bash |
| 146 | +npm run compile-tests # tsc — catches type errors in refactored tests |
| 147 | +npm test # full suite — confirms all 14 suites pass |
| 148 | +``` |
| 149 | + |
| 150 | +Both must exit 0 with zero errors. |
| 151 | + |
| 152 | +## Test scenarios |
| 153 | + |
| 154 | +**T1 — Interface satisfies structurally** |
| 155 | +`npx tsc --noEmit` on `src/vscode/startup.ts` without changing it → zero errors. Proves `vscode.WorkspaceConfiguration` satisfies `IWorkspaceConfigReader`. |
| 156 | + |
| 157 | +**T2 — FakeWorkspaceConfig key lookup** |
| 158 | +`new FakeWorkspaceConfig({ base: '/tmp/x' }).get('base')` returns `'/tmp/x'`. |
| 159 | +`new FakeWorkspaceConfig({}).get('base', 'fallback')` returns `'fallback'`. |
| 160 | +Verifiable in `node -e` without VS Code. |
| 161 | + |
| 162 | +**T3 — No live config mutation in tests** |
| 163 | +`grep -rn "config\.update\|ConfigurationTarget" src/test/suite/` → zero results. |
| 164 | + |
| 165 | +**T4 — No getConfiguration in tests** |
| 166 | +`grep -rn "vscode\.workspace\.getConfiguration" src/test/suite/` → zero results. |
| 167 | + |
| 168 | +**T5 — No dead original\* variables** |
| 169 | +`grep -rn "originalBase\|originalScopes\|originalMode\|originalExt" src/test/suite/` → zero results. |
| 170 | + |
| 171 | +**T6 — Compile clean** |
| 172 | +`npm run compile` → exit 0. `npm run compile-tests` → exit 0. |
| 173 | + |
| 174 | +**T7 — All tests green** |
| 175 | +`npm test` → all suites pass, same count as before. |
| 176 | + |
| 177 | +## Dependencies |
| 178 | +- **#198 must merge first.** After #198 lands, `TemplateService` is behind `IRawConfigProvider`. This plan then completes the isolation story: `FakeWorkspaceConfig` enables constructing `Configuration` without VS Code, which in turn means tests can supply a testable `IRawConfigProvider` (i.e., a real `Configuration(fakeConfig)`) without the Extension Host. |
| 179 | + |
| 180 | +## Risk |
| 181 | +**Risk:** A test currently uses `config.update` mid-test (not just in `beforeEach`) to change settings between assertions. Switching to `FakeWorkspaceConfig` would require constructing a new `ctrl` rather than mutating settings. |
| 182 | +**Mitigation:** Step 6 audit — read each test file before refactoring. If a mid-test `config.update` is found, split the test into two tests each with its own `ctrl`. |
| 183 | + |
| 184 | +**Risk:** `commands-prev-next.test.ts` `afterEach` blocks contain both config resets AND `showInformationMessage` restores. Partial deletion could leave orphaned `original*` variables. |
| 185 | +**Mitigation:** T5 grep catches any lingering `original*` variables. Audit `afterEach` content before deleting. |
| 186 | + |
| 187 | +## Rollback |
| 188 | +`git revert <merge-commit>` — no schema, no migration, no data. Pure TypeScript + test change. |
0 commit comments