From 5d9c62f8b41a1067a994a8d31bb15dc2e317729c Mon Sep 17 00:00:00 2001 From: Jan Scheffler Date: Tue, 21 Apr 2026 23:55:26 +0200 Subject: [PATCH] test(e2e): cover tfvc.setPat command branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three branches — cancel / store / clear — were uncovered. A refactor that conflated them (e.g. showed "stored" when actually clearing) would ship to users. Catch that by executing each branch with the input-box stubbed to return undefined / a value / empty string, and asserting which toast fires. Can't read the secret store directly from tests (ExtensionContext isn't exposed to test code), so the assertions target user-visible behaviour. A suiteTeardown clears the secret afterwards so the test profile doesn't inherit a stored PAT between runs. e2e count: 16 → 20. --- CHANGELOG.md | 1 + test-e2e/suite/setPat.test.ts | 140 ++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 test-e2e/suite/setPat.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8803937..7ba0db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `TfvcRepository` no longer imports `vscode` directly. The change-event emitter is now passed in via the constructor (`ChangeEmitter` interface — structurally compatible with `vscode.EventEmitter`), so the class can be unit-tested without a vscode runtime. `outputChannel.ts` lazy-loads `vscode` and falls back to `console.error` when it's not available, for the same reason. ### Added +- `test-e2e/suite/setPat.test.ts` — 4 cases covering the `tfvc.setPat` command: cancel (undefined input) is a no-op, submitting a value fires the "stored" toast, empty string fires the "removed" toast, and the stored/removed messages are distinct (so a refactor that conflates them gets caught). - `test-e2e/suite/notConfigured.test.ts` expanded from 1 case to 14: every palette-invokable `tfvc.*` SCM command (`refresh`, `checkin`, `sync`, `checkout`, `undo`, `undoAll`, `add`, `delete`, `shelve`, `unshelve`, `shelvesets`, `history`) now has a regression test asserting it shows the "configure settings" toast when fired unconfigured. `tfvc.initWorkspace` has its own case (different guard path). `tfvc.setPat` explicitly verifies the "always available" contract. Catches v0.3.3-class regressions in the `wrapSCM → notConfigured()` pipeline. - `src/ttlCache.ts` — reusable TTL cache, replacing the ad-hoc `{ content, timestamp }` Map in `TfvcQuickDiffProvider`. Clock injection makes expiry-boundary behaviour testable without real timers. `test/ttlCache.test.ts` adds 9 cases covering hit / miss / boundary / set-resets-window / delete / clear / lazy-eviction contract. - `src/autoCheckoutHelpers.ts` — extracts the two pure filesystem predicates the handler uses (`isPathWithinWorkspace`, `isReadOnly`) so they can be tested without a vscode runtime. `AutoCheckoutHandler` now delegates to them; `test/autoCheckoutHelpers.test.ts` adds 11 cases covering inside/outside/equal, trailing-slash and look-alike-prefix edge cases, and readable/writable/missing/directory modes. diff --git a/test-e2e/suite/setPat.test.ts b/test-e2e/suite/setPat.test.ts new file mode 100644 index 0000000..773ef18 --- /dev/null +++ b/test-e2e/suite/setPat.test.ts @@ -0,0 +1,140 @@ +/** + * `tfvc.setPat` command contract: + * - User cancels prompt (undefined) → no-op, no toast. + * - User submits empty string → secret cleared, "removed" toast. + * - User submits a value → secret stored, "stored" toast. + * + * Can't read the secret store directly — it belongs to the extension's + * ExtensionContext, which tests don't have a handle to. Instead we + * verify user-visible behaviour (which toast fires) and document the + * three branches so a refactor that conflates them shows up as a + * failing assertion. + */ + +import * as assert from 'assert'; +import * as vscode from 'vscode'; + +const EXTENSION_ID = 'qodev.tfvc'; + +interface StubbedPrompts { + /** Value returned by `showInputBox` on next call. */ + nextInputBoxValue: string | undefined; + /** Info toasts captured in order. */ + infoToasts: string[]; + /** Error toasts captured in order. */ + errorToasts: string[]; + /** Restore the original vscode.window functions. */ + restore(): void; +} + +function stubPrompts(value: string | undefined): StubbedPrompts { + const infoToasts: string[] = []; + const errorToasts: string[] = []; + + const originalInput = vscode.window.showInputBox; + const originalInfo = vscode.window.showInformationMessage; + const originalError = vscode.window.showErrorMessage; + + (vscode.window as unknown as { showInputBox: typeof vscode.window.showInputBox }) + .showInputBox = (() => Promise.resolve(value)) as typeof vscode.window.showInputBox; + + (vscode.window as unknown as { showInformationMessage: typeof vscode.window.showInformationMessage }) + .showInformationMessage = ((msg: string) => { + infoToasts.push(msg); + return Promise.resolve(undefined); + }) as typeof vscode.window.showInformationMessage; + + (vscode.window as unknown as { showErrorMessage: typeof vscode.window.showErrorMessage }) + .showErrorMessage = ((msg: string) => { + errorToasts.push(msg); + return Promise.resolve(undefined); + }) as typeof vscode.window.showErrorMessage; + + return { + nextInputBoxValue: value, + infoToasts, + errorToasts, + restore() { + (vscode.window as unknown as { showInputBox: typeof vscode.window.showInputBox }) + .showInputBox = originalInput; + (vscode.window as unknown as { showInformationMessage: typeof vscode.window.showInformationMessage }) + .showInformationMessage = originalInfo; + (vscode.window as unknown as { showErrorMessage: typeof vscode.window.showErrorMessage }) + .showErrorMessage = originalError; + }, + }; +} + +suite('tfvc.setPat', () => { + suiteSetup(async () => { + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(ext); + if (!ext!.isActive) { await ext!.activate(); } + }); + + suiteTeardown(async () => { + // Leave the secret store empty so subsequent tests (or the next + // CI run) don't inherit a stored PAT. + const stub = stubPrompts(''); + try { + await vscode.commands.executeCommand('tfvc.setPat'); + } finally { + stub.restore(); + } + }); + + test('cancel (undefined input) is a no-op — no toasts, no error', async () => { + const stub = stubPrompts(undefined); + try { + await vscode.commands.executeCommand('tfvc.setPat'); + } finally { + stub.restore(); + } + assert.deepStrictEqual(stub.infoToasts, [], 'cancel must not show info toast'); + assert.deepStrictEqual(stub.errorToasts, [], 'cancel must not show error toast'); + }); + + test('submitting a value shows the "stored" info toast', async () => { + const stub = stubPrompts('fake-pat-value'); + try { + await vscode.commands.executeCommand('tfvc.setPat'); + } finally { + stub.restore(); + } + assert.strictEqual(stub.infoToasts.length, 1, `expected one info toast, got: ${JSON.stringify(stub.infoToasts)}`); + assert.match(stub.infoToasts[0], /stored/i, `expected "stored" in: ${stub.infoToasts[0]}`); + assert.deepStrictEqual(stub.errorToasts, []); + }); + + test('submitting empty string shows the "removed" info toast', async () => { + const stub = stubPrompts(''); + try { + await vscode.commands.executeCommand('tfvc.setPat'); + } finally { + stub.restore(); + } + assert.strictEqual(stub.infoToasts.length, 1, `expected one info toast, got: ${JSON.stringify(stub.infoToasts)}`); + assert.match(stub.infoToasts[0], /removed/i, `expected "removed" in: ${stub.infoToasts[0]}`); + assert.deepStrictEqual(stub.errorToasts, []); + }); + + test('the three branches produce distinct messages (stored vs removed)', async () => { + // Regression guard against a refactor that conflates the two paths. + const store = stubPrompts('x'); + try { + await vscode.commands.executeCommand('tfvc.setPat'); + } finally { + store.restore(); + } + const clear = stubPrompts(''); + try { + await vscode.commands.executeCommand('tfvc.setPat'); + } finally { + clear.restore(); + } + assert.notStrictEqual( + store.infoToasts[0], clear.infoToasts[0], + 'stored/removed toasts must differ so users know what actually happened', + ); + }); +});