/** * Tests for keyman.update module * * Every network call, clock read and spawn is injected, so nothing here * reaches a registry or the user's home directory. */ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { buildSelfUpdateCommand, type Channel, channelForVersion, checkForUpdate, detectPackageManager, fetchChannelVersion, formatCommand, formatUpdateNotice, getUpdateCachePath, isUpdateCheckDisabled, NPMJS_REGISTRY, normalizeRegistry, readUpdateCache, resolveRegistry, selfUpdate, type UpdateCache, updateNotice, writeUpdateCache, } from '../src/keyman.update.js'; const GITEA = 'https://gitea.bitsquare.dev/api/packages/BitSquare/npm/'; let tmpDir: string; let cachePath: string; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-update-')); cachePath = path.join(tmpDir, 'update-check.json'); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); /** A fetch stand-in returning the given dist-tags */ function fakeFetch(distTags: Record, ok = true): typeof fetch { return (async () => ({ ok, json: async () => ({ 'dist-tags': distTags }), }) as Response) as unknown as typeof fetch; } describe('channelForVersion', () => { it('maps a clean release to latest', () => { expect(channelForVersion('0.5.0')).toBe('latest'); expect(channelForVersion('1.2.3')).toBe('latest'); }); it('maps a snapshot to main', () => { expect(channelForVersion('0.5.0-main.14.g6ecb2c3')).toBe('main'); }); it('maps any other prerelease to next', () => { expect(channelForVersion('0.6.0-rc.1')).toBe('next'); expect(channelForVersion('1.0.0-alpha5')).toBe('next'); }); it('treats an unparseable version as latest', () => { expect(channelForVersion('not-a-version')).toBe('latest'); expect(channelForVersion('')).toBe('latest'); }); }); describe('normalizeRegistry', () => { it('adds a trailing slash', () => { expect(normalizeRegistry('https://example.com/npm')).toBe('https://example.com/npm/'); }); it('leaves an existing trailing slash alone', () => { expect(normalizeRegistry(GITEA)).toBe(GITEA); }); it('trims surrounding whitespace', () => { expect(normalizeRegistry(' https://example.com/npm ')).toBe('https://example.com/npm/'); }); }); describe('resolveRegistry', () => { it('prefers the KEYMAN_REGISTRY override', async () => { const run = vi.fn(); const registry = await resolveRegistry({ env: { KEYMAN_REGISTRY: 'https://example.com/npm' }, run, }); expect(registry).toBe('https://example.com/npm/'); expect(run).not.toHaveBeenCalled(); }); it('falls back to npm config', async () => { const run = vi.fn(async () => GITEA); expect(await resolveRegistry({ env: {}, run })).toBe(GITEA); expect(run).toHaveBeenCalledWith('npm', ['config', 'get', '@bitsquare:registry']); }); it('treats npm printing "undefined" as unset', async () => { const run = vi.fn(async () => 'undefined'); expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY); }); it('treats npm printing "null" as unset', async () => { const run = vi.fn(async () => 'null'); expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY); }); it('treats empty output as unset', async () => { const run = vi.fn(async () => ' '); expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY); }); it('falls back to npmjs when npm is missing', async () => { const run = vi.fn(async () => { throw new Error('ENOENT'); }); expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY); }); it('ignores a blank override', async () => { const run = vi.fn(async () => GITEA); expect(await resolveRegistry({ env: { KEYMAN_REGISTRY: ' ' }, run })).toBe(GITEA); }); }); describe('fetchChannelVersion', () => { it('reads the requested dist-tag', async () => { const version = await fetchChannelVersion({ registry: GITEA, channel: 'main', fetchImpl: fakeFetch({ main: '0.5.0-main.14.gabc1234', latest: '0.5.0' }), }); expect(version).toBe('0.5.0-main.14.gabc1234'); }); it('returns null when the tag does not exist', async () => { const version = await fetchChannelVersion({ registry: GITEA, channel: 'latest', fetchImpl: fakeFetch({ main: '0.5.0-main.14.gabc1234' }), }); expect(version).toBeNull(); }); it('returns null on a non-ok response', async () => { const version = await fetchChannelVersion({ registry: GITEA, channel: 'latest', fetchImpl: fakeFetch({}, false), }); expect(version).toBeNull(); }); it('returns null when the packument has no dist-tags at all', async () => { const fetchImpl = (async () => ({ ok: true, json: async () => ({}) }) as Response) as unknown as typeof fetch; expect(await fetchChannelVersion({ registry: GITEA, channel: 'latest', fetchImpl })).toBeNull(); }); it('url-encodes the scoped package name onto the registry', async () => { const seen: string[] = []; const fetchImpl = (async (url: string) => { seen.push(url); return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.5.0' } }) } as Response; }) as unknown as typeof fetch; // No trailing slash on purpose: it must be normalised before joining. await fetchChannelVersion({ registry: 'https://example.com/npm', channel: 'latest', fetchImpl, }); expect(seen[0]).toBe('https://example.com/npm/%40bitsquare%2Fkeyman'); }); it('sends a bearer token when one is given', async () => { let headers: Record = {}; const fetchImpl = (async (_url: string, init: RequestInit) => { headers = init.headers as Record; return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.5.0' } }) } as Response; }) as unknown as typeof fetch; await fetchChannelVersion({ registry: GITEA, channel: 'latest', token: 'secret', fetchImpl }); expect(headers.authorization).toBe('Bearer secret'); }); it('omits the authorization header when no token is given', async () => { let headers: Record = {}; const fetchImpl = (async (_url: string, init: RequestInit) => { headers = init.headers as Record; return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.5.0' } }) } as Response; }) as unknown as typeof fetch; await fetchChannelVersion({ registry: GITEA, channel: 'latest', fetchImpl }); expect(headers.authorization).toBeUndefined(); }); }); describe('the update cache', () => { it('round-trips', () => { const cache: UpdateCache = { checkedAt: '2026-07-29T00:00:00.000Z', channel: 'latest', registry: NPMJS_REGISTRY, latest: '0.6.0', }; writeUpdateCache(cache, cachePath); expect(readUpdateCache(cachePath)).toEqual(cache); }); it('creates the containing directory', () => { const nested = path.join(tmpDir, 'a', 'b', 'update-check.json'); writeUpdateCache( { checkedAt: '2026-07-29T00:00:00.000Z', channel: 'latest', registry: NPMJS_REGISTRY, latest: null, }, nested ); expect(fs.existsSync(nested)).toBe(true); }); it('reads a missing file as null', () => { expect(readUpdateCache(path.join(tmpDir, 'absent.json'))).toBeNull(); }); it('reads malformed JSON as null', () => { fs.writeFileSync(cachePath, '{ not json', 'utf-8'); expect(readUpdateCache(cachePath)).toBeNull(); }); it('rejects a file without a checkedAt stamp', () => { fs.writeFileSync(cachePath, JSON.stringify({ latest: '9.9.9' }), 'utf-8'); expect(readUpdateCache(cachePath)).toBeNull(); }); it('swallows a write it cannot perform', () => { // A path whose parent is a file, not a directory. const blocked = path.join(cachePath, 'nested.json'); fs.writeFileSync(cachePath, '{}', 'utf-8'); expect(() => writeUpdateCache( { checkedAt: '2026-07-29T00:00:00.000Z', channel: 'latest', registry: NPMJS_REGISTRY, latest: null, }, blocked ) ).not.toThrow(); }); it('defaults to a path under the home directory', () => { expect(getUpdateCachePath('/home/someone')).toBe('/home/someone/.keyman/update-check.json'); }); }); describe('isUpdateCheckDisabled', () => { it('is off by default', () => { expect(isUpdateCheckDisabled({})).toBe(false); }); it('honours KEYMAN_NO_UPDATE_CHECK', () => { expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: '1' })).toBe(true); expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: 'yes' })).toBe(true); }); it('treats 0 and false as not disabled', () => { expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: '0' })).toBe(false); expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: 'false' })).toBe(false); expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: '' })).toBe(false); }); it('disables itself in CI', () => { expect(isUpdateCheckDisabled({ CI: 'true' })).toBe(true); }); }); describe('checkForUpdate', () => { const base = { currentVersion: '0.5.0', registry: NPMJS_REGISTRY, env: {} as NodeJS.ProcessEnv, now: Date.parse('2026-07-29T12:00:00.000Z'), }; it('reports a newer version on the channel', async () => { const status = await checkForUpdate({ ...base, cachePath, fetchImpl: fakeFetch({ latest: '0.6.0' }), }); expect(status).toMatchObject({ current: '0.5.0', latest: '0.6.0', channel: 'latest', updateAvailable: true, fromCache: false, }); }); it('reports no update when the channel matches', async () => { const status = await checkForUpdate({ ...base, cachePath, fetchImpl: fakeFetch({ latest: '0.5.0' }), }); expect(status.updateAvailable).toBe(false); }); it('does not treat an older published version as an update', async () => { const status = await checkForUpdate({ ...base, cachePath, fetchImpl: fakeFetch({ latest: '0.4.0' }), }); expect(status.updateAvailable).toBe(false); }); it('derives the channel from the running version', async () => { const status = await checkForUpdate({ ...base, currentVersion: '0.5.0-main.13.gabc1234', cachePath, fetchImpl: fakeFetch({ main: '0.5.0-main.14.gdef5678', latest: '0.5.0' }), }); expect(status.channel).toBe('main'); expect(status.latest).toBe('0.5.0-main.14.gdef5678'); expect(status.updateAvailable).toBe(true); }); it('writes what it found to the cache', async () => { await checkForUpdate({ ...base, cachePath, fetchImpl: fakeFetch({ latest: '0.6.0' }) }); expect(readUpdateCache(cachePath)).toEqual({ checkedAt: '2026-07-29T12:00:00.000Z', channel: 'latest', registry: NPMJS_REGISTRY, latest: '0.6.0', }); }); it('answers from a fresh cache without touching the network', async () => { writeUpdateCache( { checkedAt: '2026-07-29T11:00:00.000Z', channel: 'latest', registry: NPMJS_REGISTRY, latest: '0.7.0', }, cachePath ); const fetchImpl = vi.fn(); const status = await checkForUpdate({ ...base, cachePath, fetchImpl: fetchImpl as unknown as typeof fetch, }); expect(status.latest).toBe('0.7.0'); expect(status.fromCache).toBe(true); expect(fetchImpl).not.toHaveBeenCalled(); }); it('refetches once the cache goes stale', async () => { writeUpdateCache( { checkedAt: '2026-07-27T11:00:00.000Z', channel: 'latest', registry: NPMJS_REGISTRY, latest: '0.7.0', }, cachePath ); const status = await checkForUpdate({ ...base, cachePath, fetchImpl: fakeFetch({ latest: '0.8.0' }), }); expect(status.latest).toBe('0.8.0'); expect(status.fromCache).toBe(false); }); it('ignores a cache written for a different channel', async () => { writeUpdateCache( { checkedAt: '2026-07-29T11:00:00.000Z', channel: 'next', registry: NPMJS_REGISTRY, latest: '9.9.9', }, cachePath ); const status = await checkForUpdate({ ...base, cachePath, fetchImpl: fakeFetch({ latest: '0.6.0' }), }); expect(status.latest).toBe('0.6.0'); }); it('ignores a cache written for a different registry', async () => { writeUpdateCache( { checkedAt: '2026-07-29T11:00:00.000Z', channel: 'latest', registry: GITEA, latest: '9.9.9', }, cachePath ); const status = await checkForUpdate({ ...base, cachePath, fetchImpl: fakeFetch({ latest: '0.6.0' }), }); expect(status.latest).toBe('0.6.0'); }); it('ignores a cache stamped in the future', async () => { writeUpdateCache( { checkedAt: '2027-01-01T00:00:00.000Z', channel: 'latest', registry: NPMJS_REGISTRY, latest: '9.9.9', }, cachePath ); const status = await checkForUpdate({ ...base, cachePath, fetchImpl: fakeFetch({ latest: '0.6.0' }), }); expect(status.latest).toBe('0.6.0'); }); it('ignores a cache with an unparseable stamp', async () => { fs.writeFileSync( cachePath, JSON.stringify({ checkedAt: 'whenever', channel: 'latest', registry: NPMJS_REGISTRY, latest: '9.9.9', }), 'utf-8' ); const status = await checkForUpdate({ ...base, cachePath, fetchImpl: fakeFetch({ latest: '0.6.0' }), }); expect(status.latest).toBe('0.6.0'); }); it('refetches when forced, even with a fresh cache', async () => { writeUpdateCache( { checkedAt: '2026-07-29T11:00:00.000Z', channel: 'latest', registry: NPMJS_REGISTRY, latest: '0.7.0', }, cachePath ); const status = await checkForUpdate({ ...base, cachePath, force: true, fetchImpl: fakeFetch({ latest: '0.9.0' }), }); expect(status.latest).toBe('0.9.0'); expect(status.fromCache).toBe(false); }); it('falls back to the cached answer when the network fails', async () => { writeUpdateCache( { checkedAt: '2026-07-20T11:00:00.000Z', channel: 'latest', registry: NPMJS_REGISTRY, latest: '0.7.0', }, cachePath ); const fetchImpl = (async () => { throw new Error('offline'); }) as unknown as typeof fetch; const status = await checkForUpdate({ ...base, cachePath, fetchImpl }); expect(status.latest).toBe('0.7.0'); expect(status.updateAvailable).toBe(true); expect(status.fromCache).toBe(true); }); it('reports nothing when the network fails and no cache applies', async () => { const fetchImpl = (async () => { throw new Error('offline'); }) as unknown as typeof fetch; const status = await checkForUpdate({ ...base, cachePath, fetchImpl }); expect(status.latest).toBeNull(); expect(status.updateAvailable).toBe(false); }); it('resolves the registry when none is given', async () => { const status = await checkForUpdate({ currentVersion: '0.5.0', cachePath, env: {}, run: async () => GITEA, fetchImpl: fakeFetch({ latest: '0.6.0' }), }); expect(status.registry).toBe(GITEA); }); it('passes a registry token from the environment through', async () => { let headers: Record = {}; const fetchImpl = (async (_url: string, init: RequestInit) => { headers = init.headers as Record; return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.6.0' } }) } as Response; }) as unknown as typeof fetch; await checkForUpdate({ ...base, cachePath, env: { KEYMAN_REGISTRY_TOKEN: 'tok' }, fetchImpl, }); expect(headers.authorization).toBe('Bearer tok'); }); it('does not compare against an unparseable current version', async () => { const status = await checkForUpdate({ ...base, currentVersion: 'dev', cachePath, fetchImpl: fakeFetch({ latest: '0.6.0' }), }); expect(status.updateAvailable).toBe(false); }); }); describe('detectPackageManager', () => { it('honours the environment override', () => { expect( detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'pnpm' }, execPath: '/usr/lib/x' }) ).toBe('pnpm'); expect( detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'YARN' }, execPath: '/usr/lib/x' }) ).toBe('yarn'); expect( detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'bun' }, execPath: '/usr/lib/x' }) ).toBe('bun'); expect( detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'npm' }, execPath: '/x/pnpm/y' }) ).toBe('npm'); }); it('ignores an unrecognised override', () => { expect( detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'cargo' }, execPath: '/usr/lib/x' }) ).toBe('npm'); }); it('recognises a pnpm global install', () => { expect( detectPackageManager({ env: {}, execPath: '/Users/x/Library/pnpm/global/5/node_modules/.bin/keyman', }) ).toBe('pnpm'); }); it('recognises a bun global install', () => { expect( detectPackageManager({ env: {}, execPath: '/Users/x/.bun/install/global/node_modules/keyman', }) ).toBe('bun'); }); it('recognises a yarn global install', () => { expect(detectPackageManager({ env: {}, execPath: '/Users/x/.yarn/bin/keyman' })).toBe('yarn'); }); it('defaults to npm', () => { expect( detectPackageManager({ env: {}, execPath: '/usr/local/lib/node_modules/@bitsquare/keyman/dist/keyman.cli.js', }) ).toBe('npm'); }); it('handles a windows-style path and an empty path', () => { expect( detectPackageManager({ env: {}, execPath: 'C:\\Users\\x\\AppData\\Local\\pnpm\\keyman.exe' }) ).toBe('pnpm'); expect(detectPackageManager({ env: {}, execPath: '' })).toBe('npm'); }); }); describe('buildSelfUpdateCommand', () => { it('builds an npm global install without a registry flag for npmjs', () => { const command = buildSelfUpdateCommand({ packageManager: 'npm', channel: 'latest', registry: NPMJS_REGISTRY, }); expect(formatCommand(command)).toBe('npm install --global @bitsquare/keyman@latest'); }); it('adds a scoped registry override for a non-npmjs registry', () => { const command = buildSelfUpdateCommand({ packageManager: 'npm', channel: 'main', registry: GITEA, }); // Scoped, not `--registry`: Gitea does not proxy npmjs, so the transitive // dependencies have to keep resolving from npmjs. expect(formatCommand(command)).toBe( `npm install --global @bitsquare/keyman@main --@bitsquare:registry=${GITEA}` ); expect(command.args).not.toContain('--registry'); }); it('normalises a registry given without a trailing slash', () => { const command = buildSelfUpdateCommand({ packageManager: 'npm', channel: 'latest', registry: 'https://registry.npmjs.org', }); expect(command.args).toEqual(['install', '--global', '@bitsquare/keyman@latest']); }); it('builds for pnpm, yarn and bun', () => { expect( formatCommand( buildSelfUpdateCommand({ packageManager: 'pnpm', channel: 'next', registry: NPMJS_REGISTRY, }) ) ).toBe('pnpm add --global @bitsquare/keyman@next'); expect( formatCommand( buildSelfUpdateCommand({ packageManager: 'yarn', channel: 'next', registry: NPMJS_REGISTRY, }) ) ).toBe('yarn global add @bitsquare/keyman@next'); expect( formatCommand( buildSelfUpdateCommand({ packageManager: 'bun', channel: 'next', registry: NPMJS_REGISTRY }) ) ).toBe('bun add --global @bitsquare/keyman@next'); }); it('accepts an explicit package name', () => { const command = buildSelfUpdateCommand({ packageManager: 'npm', channel: 'latest', registry: NPMJS_REGISTRY, packageName: '@bitsquare/nopy', }); expect(formatCommand(command)).toBe('npm install --global @bitsquare/nopy@latest'); }); }); describe('formatUpdateNotice', () => { const status = { current: '0.5.0', latest: '0.6.0', channel: 'latest' as Channel, registry: NPMJS_REGISTRY, updateAvailable: true, fromCache: false, }; it('names both versions and the command', () => { const notice = formatUpdateNotice(status, 'npm'); expect(notice).toContain('0.5.0 -> 0.6.0'); expect(notice).toContain('keyman self-update'); expect(notice).toContain('npm install --global @bitsquare/keyman@latest'); }); it('names a non-default channel', () => { expect(formatUpdateNotice({ ...status, channel: 'main' }, 'npm')).toContain('(main)'); }); it('says nothing when there is no update', () => { expect(formatUpdateNotice({ ...status, updateAvailable: false }, 'npm')).toBeNull(); }); it('says nothing when the latest version is unknown', () => { expect(formatUpdateNotice({ ...status, latest: null }, 'npm')).toBeNull(); }); it('detects the package manager when none is given', () => { expect(formatUpdateNotice(status)).toContain('@bitsquare/keyman@latest'); }); }); describe('updateNotice', () => { it('returns a notice when an update exists', async () => { const notice = await updateNotice({ currentVersion: '0.5.0', env: { KEYMAN_REGISTRY: NPMJS_REGISTRY }, cachePath, now: Date.parse('2026-07-29T12:00:00.000Z'), fetchImpl: fakeFetch({ latest: '0.6.0' }), }); expect(notice).toContain('0.5.0 -> 0.6.0'); }); it('returns null when the check is disabled', async () => { const fetchImpl = vi.fn(); const notice = await updateNotice({ currentVersion: '0.5.0', env: { KEYMAN_NO_UPDATE_CHECK: '1' }, cachePath, fetchImpl: fetchImpl as unknown as typeof fetch, }); expect(notice).toBeNull(); expect(fetchImpl).not.toHaveBeenCalled(); }); it('returns null rather than throwing when everything fails', async () => { const notice = await updateNotice({ currentVersion: '0.5.0', env: {}, cachePath, run: async () => { throw new Error('no npm'); }, fetchImpl: (async () => { throw new Error('offline'); }) as unknown as typeof fetch, }); expect(notice).toBeNull(); }); }); describe('selfUpdate', () => { const base = { currentVersion: '0.5.0', env: { KEYMAN_REGISTRY: NPMJS_REGISTRY } as NodeJS.ProcessEnv, packageManager: 'npm' as const, }; it('runs the install when a newer version exists', async () => { const spawn = vi.fn(async () => undefined); const result = await selfUpdate({ ...base, cachePath, fetchImpl: fakeFetch({ latest: '0.6.0' }), spawn, }); expect(result.ran).toBe(true); expect(spawn).toHaveBeenCalledWith('npm', ['install', '--global', '@bitsquare/keyman@latest']); }); it('does nothing when already up to date', async () => { const spawn = vi.fn(async () => undefined); const result = await selfUpdate({ ...base, cachePath, fetchImpl: fakeFetch({ latest: '0.5.0' }), spawn, }); expect(result.ran).toBe(false); expect(spawn).not.toHaveBeenCalled(); }); it('reinstalls when forced', async () => { const spawn = vi.fn(async () => undefined); const result = await selfUpdate({ ...base, cachePath, force: true, fetchImpl: fakeFetch({ latest: '0.5.0' }), spawn, }); expect(result.ran).toBe(true); }); it('reports the command without running it on a dry run', async () => { const spawn = vi.fn(async () => undefined); const result = await selfUpdate({ ...base, cachePath, dryRun: true, fetchImpl: fakeFetch({ latest: '0.6.0' }), spawn, }); expect(result.ran).toBe(false); expect(spawn).not.toHaveBeenCalled(); expect(formatCommand(result.command)).toBe('npm install --global @bitsquare/keyman@latest'); }); it('ignores a fresh cache, because the user asked', async () => { writeUpdateCache( { checkedAt: new Date().toISOString(), channel: 'latest', registry: NPMJS_REGISTRY, latest: '0.5.0', }, cachePath ); const result = await selfUpdate({ ...base, cachePath, fetchImpl: fakeFetch({ latest: '0.6.0' }), spawn: async () => undefined, }); expect(result.status.latest).toBe('0.6.0'); expect(result.ran).toBe(true); }); it('follows an explicit channel and registry', async () => { const result = await selfUpdate({ currentVersion: '0.5.0', env: {}, packageManager: 'pnpm', channel: 'main', registry: GITEA, cachePath, fetchImpl: fakeFetch({ main: '0.5.0-main.20.gaaaaaaa' }), spawn: async () => undefined, }); expect(formatCommand(result.command)).toBe( `pnpm add --global @bitsquare/keyman@main --@bitsquare:registry=${GITEA}` ); }); it('does not run when the registry could not be reached', async () => { const spawn = vi.fn(async () => undefined); const result = await selfUpdate({ ...base, cachePath, fetchImpl: (async () => { throw new Error('offline'); }) as unknown as typeof fetch, spawn, }); expect(result.status.latest).toBeNull(); expect(result.ran).toBe(false); expect(spawn).not.toHaveBeenCalled(); }); });