|
| 1 | +/** |
| 2 | + * Unit tests for the update notice (issue #122). Every effect is injected: |
| 3 | + * no real network, filesystem, clock, or TTY is touched. |
| 4 | + */ |
| 5 | + |
| 6 | +import { describe, expect, it, vi } from 'vitest'; |
| 7 | +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; |
| 8 | +import { tmpdir } from 'node:os'; |
| 9 | +import { join } from 'node:path'; |
| 10 | +import type { UpdateCheckDeps } from './update-check.js'; |
| 11 | +import { |
| 12 | + UPDATE_CHECK_OPT_OUT_ENV, |
| 13 | + UPDATE_CHECK_TTL_MS, |
| 14 | + compareSemver, |
| 15 | + fetchLatestVersion, |
| 16 | + maybeNotifyUpdate, |
| 17 | + shouldCheckForUpdate, |
| 18 | +} from './update-check.js'; |
| 19 | + |
| 20 | +/** In-memory fs + deterministic clock harness for the cache round-trip. */ |
| 21 | +function makeHarness(overrides: UpdateCheckDeps = {}) { |
| 22 | + const files = new Map<string, string>(); |
| 23 | + const stderrLines: string[] = []; |
| 24 | + const deps: UpdateCheckDeps = { |
| 25 | + env: {}, |
| 26 | + now: () => 1_000_000, |
| 27 | + cachePath: '/fake/.testsprite/update-check.json', |
| 28 | + readFile: path => { |
| 29 | + const content = files.get(path); |
| 30 | + if (content === undefined) throw new Error('ENOENT'); |
| 31 | + return content; |
| 32 | + }, |
| 33 | + writeFile: (path, content) => { |
| 34 | + files.set(path, content); |
| 35 | + }, |
| 36 | + mkdir: () => undefined, |
| 37 | + isTTY: true, |
| 38 | + stderr: line => stderrLines.push(line), |
| 39 | + currentVersion: '0.2.0', |
| 40 | + fetchImpl: async () => |
| 41 | + new Response(JSON.stringify({ version: '0.2.0' }), { |
| 42 | + status: 200, |
| 43 | + headers: { 'content-type': 'application/json' }, |
| 44 | + }), |
| 45 | + ...overrides, |
| 46 | + }; |
| 47 | + return { deps, files, stderrLines }; |
| 48 | +} |
| 49 | + |
| 50 | +describe('shouldCheckForUpdate gates', () => { |
| 51 | + it('opt-out env set to any non-empty value disables (even "0")', () => { |
| 52 | + const { deps } = makeHarness({ env: { [UPDATE_CHECK_OPT_OUT_ENV]: '0' } }); |
| 53 | + expect(shouldCheckForUpdate(deps)).toBe(false); |
| 54 | + }); |
| 55 | + |
| 56 | + it('CI set disables; CI="false" re-enables', () => { |
| 57 | + expect(shouldCheckForUpdate(makeHarness({ env: { CI: 'true' } }).deps)).toBe(false); |
| 58 | + expect(shouldCheckForUpdate(makeHarness({ env: { CI: '' } }).deps)).toBe(false); |
| 59 | + expect(shouldCheckForUpdate(makeHarness({ env: { CI: 'false' } }).deps)).toBe(true); |
| 60 | + }); |
| 61 | + |
| 62 | + it('non-TTY stderr disables', () => { |
| 63 | + expect(shouldCheckForUpdate(makeHarness({ isTTY: false }).deps)).toBe(false); |
| 64 | + }); |
| 65 | + |
| 66 | + it('a fresh cache suppresses; a stale cache does not', () => { |
| 67 | + const fresh = makeHarness(); |
| 68 | + fresh.files.set( |
| 69 | + '/fake/.testsprite/update-check.json', |
| 70 | + JSON.stringify({ lastCheckMs: 1_000_000 - UPDATE_CHECK_TTL_MS + 5_000 }), |
| 71 | + ); |
| 72 | + expect(shouldCheckForUpdate(fresh.deps)).toBe(false); |
| 73 | + |
| 74 | + const stale = makeHarness(); |
| 75 | + stale.files.set( |
| 76 | + '/fake/.testsprite/update-check.json', |
| 77 | + JSON.stringify({ lastCheckMs: 1_000_000 - UPDATE_CHECK_TTL_MS - 5_000 }), |
| 78 | + ); |
| 79 | + expect(shouldCheckForUpdate(stale.deps)).toBe(true); |
| 80 | + }); |
| 81 | + |
| 82 | + it('missing, corrupt, wrong-shape, or future-stamped caches count as stale', () => { |
| 83 | + expect(shouldCheckForUpdate(makeHarness().deps)).toBe(true); // missing |
| 84 | + const corrupt = makeHarness(); |
| 85 | + corrupt.files.set('/fake/.testsprite/update-check.json', '{not json'); |
| 86 | + expect(shouldCheckForUpdate(corrupt.deps)).toBe(true); |
| 87 | + const wrongShape = makeHarness(); |
| 88 | + wrongShape.files.set('/fake/.testsprite/update-check.json', JSON.stringify({ nope: true })); |
| 89 | + expect(shouldCheckForUpdate(wrongShape.deps)).toBe(true); |
| 90 | + const future = makeHarness(); |
| 91 | + future.files.set( |
| 92 | + '/fake/.testsprite/update-check.json', |
| 93 | + JSON.stringify({ lastCheckMs: 9_999_999_999 }), |
| 94 | + ); |
| 95 | + expect(shouldCheckForUpdate(future.deps)).toBe(true); |
| 96 | + }); |
| 97 | +}); |
| 98 | + |
| 99 | +describe('fetchLatestVersion', () => { |
| 100 | + it('returns the version from a valid registry body', async () => { |
| 101 | + const { deps } = makeHarness({ |
| 102 | + fetchImpl: async () => new Response(JSON.stringify({ version: '1.2.3' }), { status: 200 }), |
| 103 | + }); |
| 104 | + await expect(fetchLatestVersion(deps)).resolves.toBe('1.2.3'); |
| 105 | + }); |
| 106 | + |
| 107 | + it('returns undefined on non-2xx, thrown fetch, and wrong-shape body', async () => { |
| 108 | + const notOk = makeHarness({ fetchImpl: async () => new Response('nope', { status: 500 }) }); |
| 109 | + await expect(fetchLatestVersion(notOk.deps)).resolves.toBeUndefined(); |
| 110 | + |
| 111 | + const throwing = makeHarness({ |
| 112 | + fetchImpl: async () => { |
| 113 | + throw new TypeError('fetch failed'); |
| 114 | + }, |
| 115 | + }); |
| 116 | + await expect(fetchLatestVersion(throwing.deps)).resolves.toBeUndefined(); |
| 117 | + |
| 118 | + const wrongShape = makeHarness({ |
| 119 | + fetchImpl: async () => new Response(JSON.stringify({ notVersion: 1 }), { status: 200 }), |
| 120 | + }); |
| 121 | + await expect(fetchLatestVersion(wrongShape.deps)).resolves.toBeUndefined(); |
| 122 | + }); |
| 123 | +}); |
| 124 | + |
| 125 | +describe('compareSemver', () => { |
| 126 | + it('orders numerically and treats prerelease as older than its release', () => { |
| 127 | + expect(compareSemver('0.2.0', '0.3.0')).toBe(-1); |
| 128 | + expect(compareSemver('1.0.0', '0.9.9')).toBe(1); |
| 129 | + expect(compareSemver('0.2.0', '0.2.0')).toBe(0); |
| 130 | + expect(compareSemver('0.10.0', '0.9.0')).toBe(1); // numeric, not lexicographic |
| 131 | + expect(compareSemver('1.0.0-rc.1', '1.0.0')).toBe(-1); |
| 132 | + expect(compareSemver('1.0.0', '1.0.0-rc.1')).toBe(1); |
| 133 | + }); |
| 134 | + |
| 135 | + it('unparseable input on either side compares as 0 (never a false notice)', () => { |
| 136 | + expect(compareSemver('garbage', '1.0.0')).toBe(0); |
| 137 | + expect(compareSemver('1.0.0', '')).toBe(0); |
| 138 | + }); |
| 139 | +}); |
| 140 | + |
| 141 | +describe('maybeNotifyUpdate', () => { |
| 142 | + it('prints exactly one stderr line naming both versions when newer, and stamps the cache', async () => { |
| 143 | + const harness = makeHarness({ |
| 144 | + fetchImpl: async () => new Response(JSON.stringify({ version: '0.3.1' }), { status: 200 }), |
| 145 | + }); |
| 146 | + await maybeNotifyUpdate(harness.deps); |
| 147 | + expect(harness.stderrLines).toHaveLength(1); |
| 148 | + expect(harness.stderrLines[0]).toContain('0.2.0 -> 0.3.1'); |
| 149 | + expect(harness.stderrLines[0]).toContain(UPDATE_CHECK_OPT_OUT_ENV); |
| 150 | + const cache = JSON.parse(harness.files.get('/fake/.testsprite/update-check.json')!) as { |
| 151 | + lastCheckMs: number; |
| 152 | + latestKnown?: string; |
| 153 | + }; |
| 154 | + expect(cache.lastCheckMs).toBe(1_000_000); |
| 155 | + expect(cache.latestKnown).toBe('0.3.1'); |
| 156 | + }); |
| 157 | + |
| 158 | + it('stays silent on an equal or older registry version', async () => { |
| 159 | + const equal = makeHarness(); |
| 160 | + await maybeNotifyUpdate(equal.deps); |
| 161 | + expect(equal.stderrLines).toHaveLength(0); |
| 162 | + |
| 163 | + const older = makeHarness({ |
| 164 | + fetchImpl: async () => new Response(JSON.stringify({ version: '0.1.9' }), { status: 200 }), |
| 165 | + }); |
| 166 | + await maybeNotifyUpdate(older.deps); |
| 167 | + expect(older.stderrLines).toHaveLength(0); |
| 168 | + }); |
| 169 | + |
| 170 | + it('a failed probe stays silent but still stamps the cache (retry once per TTL)', async () => { |
| 171 | + const harness = makeHarness({ |
| 172 | + fetchImpl: async () => { |
| 173 | + throw new TypeError('fetch failed'); |
| 174 | + }, |
| 175 | + }); |
| 176 | + await maybeNotifyUpdate(harness.deps); |
| 177 | + expect(harness.stderrLines).toHaveLength(0); |
| 178 | + const cache = JSON.parse(harness.files.get('/fake/.testsprite/update-check.json')!) as { |
| 179 | + lastCheckMs: number; |
| 180 | + latestKnown?: string; |
| 181 | + }; |
| 182 | + expect(cache.lastCheckMs).toBe(1_000_000); |
| 183 | + expect(cache.latestKnown).toBeUndefined(); |
| 184 | + }); |
| 185 | + |
| 186 | + it('does nothing when a gate blocks (no fetch fired)', async () => { |
| 187 | + const fetchImpl = vi.fn(async () => new Response('{}', { status: 200 })); |
| 188 | + const harness = makeHarness({ env: { CI: '1' }, fetchImpl }); |
| 189 | + await maybeNotifyUpdate(harness.deps); |
| 190 | + expect(fetchImpl).not.toHaveBeenCalled(); |
| 191 | + expect(harness.stderrLines).toHaveLength(0); |
| 192 | + }); |
| 193 | + |
| 194 | + it('never rejects, even when every injected dependency throws', async () => { |
| 195 | + const harness = makeHarness({ |
| 196 | + fetchImpl: async () => new Response(JSON.stringify({ version: '9.9.9' }), { status: 200 }), |
| 197 | + writeFile: () => { |
| 198 | + throw new Error('EROFS'); |
| 199 | + }, |
| 200 | + stderr: () => { |
| 201 | + throw new Error('broken stderr sink'); |
| 202 | + }, |
| 203 | + }); |
| 204 | + await expect(maybeNotifyUpdate(harness.deps)).resolves.toBeUndefined(); |
| 205 | + }); |
| 206 | + |
| 207 | + it('uses the default fs readers/writers when none are injected (real cache round-trip)', async () => { |
| 208 | + const dir = mkdtempSync(join(tmpdir(), 'update-check-')); |
| 209 | + // Point cachePath at a not-yet-existing subdir so the default mkdir |
| 210 | + // (recursive) and writeFile arrows both run, and the first read (file |
| 211 | + // absent) exercises the default readFile arrow's ENOENT path. |
| 212 | + const cachePath = join(dir, 'nested', 'update-check.json'); |
| 213 | + const stderrLines: string[] = []; |
| 214 | + try { |
| 215 | + await maybeNotifyUpdate({ |
| 216 | + env: {}, |
| 217 | + now: () => 1_000_000, |
| 218 | + isTTY: true, |
| 219 | + currentVersion: '0.0.1', |
| 220 | + cachePath, |
| 221 | + stderr: line => stderrLines.push(line), |
| 222 | + fetchImpl: async () => new Response(JSON.stringify({ version: '9.9.9' }), { status: 200 }), |
| 223 | + }); |
| 224 | + // Cache was persisted by the default writeFile through the default mkdir. |
| 225 | + expect(JSON.parse(readFileSync(cachePath, 'utf8'))).toMatchObject({ latestKnown: '9.9.9' }); |
| 226 | + expect(stderrLines.join('\n')).toContain('0.0.1 -> 9.9.9'); |
| 227 | + } finally { |
| 228 | + rmSync(dir, { recursive: true, force: true }); |
| 229 | + } |
| 230 | + }); |
| 231 | +}); |
0 commit comments