diff --git a/src/recipes/ai-context/context.ts b/src/recipes/ai-context/context.ts index 61d8c50e852..5e85eb9c08e 100644 --- a/src/recipes/ai-context/context.ts +++ b/src/recipes/ai-context/context.ts @@ -1,22 +1,94 @@ import { promises as fs } from 'node:fs' -import { dirname } from 'node:path' +import { dirname, resolve } from 'node:path' +import semver from 'semver' +import { chalk, logAndThrowError, log, version } from '../../utils/command-helpers.js' +import type { RunRecipeOptions } from '../../commands/recipes/recipes.js' const ATTRIBUTES_REGEX = /(\S*)="([^\s"]*)"/gim -const BASE_URL = 'https://docs.netlify.com/ai-context' -export const FILE_NAME = 'netlify-development.mdc' +// AI_CONTEXT_BASE_URL is used to help with local testing at non-production +// versions of the context apis. +const BASE_URL = new URL(process.env.AI_CONTEXT_BASE_URL ?? 'https://docs.netlify.com/ai-context').toString() +export const NTL_DEV_MCP_FILE_NAME = 'netlify-development.mdc' const MINIMUM_CLI_VERSION_HEADER = 'x-cli-min-ver' export const NETLIFY_PROVIDER = 'netlify' const PROVIDER_CONTEXT_REGEX = /]*)>(.*)<\/providercontext>/ims const PROVIDER_CONTEXT_OVERRIDES_REGEX = /]*)>(.*)<\/providercontextoverrides>/ims const PROVIDER_CONTEXT_OVERRIDES_TAG = 'ProviderContextOverrides' -export const downloadFile = async (cliVersion: string) => { +export interface ContextConfig { + scope: string + glob?: string + shared?: string[] + endpoint?: string +} + +export interface ContextFile { + key: string + config: ContextConfig + content: string +} + +export interface ConsumerConfig { + key: string + presentedName: string + consumerProcessCmd?: string + path: string + ext: string + truncationLimit?: number + contextScopes: Record + hideFromCLI?: boolean + consumerTrigger?: string +} + +let contextConsumers: ConsumerConfig[] = [] +export const getContextConsumers = async (cliVersion: string) => { + if (contextConsumers.length > 0) { + return contextConsumers + } try { - const res = await fetch(`${BASE_URL}/${FILE_NAME}`, { + const res = await fetch(`${BASE_URL}/context-consumers`, { headers: { 'user-agent': `NetlifyCLI ${cliVersion}`, }, }) + + if (!res.ok) { + return [] + } + + const data = (await res.json()) as { consumers: ConsumerConfig[] } | undefined + contextConsumers = data?.consumers ?? [] + } catch {} + + return contextConsumers +} + +export const downloadFile = async (cliVersion: string, contextConfig: ContextConfig, consumer: ConsumerConfig) => { + try { + if (!contextConfig.endpoint) { + return null + } + + const url = new URL(contextConfig.endpoint, BASE_URL) + url.searchParams.set('consumer', consumer.key) + + if (process.env.AI_CONTEXT_BASE_URL) { + const overridingUrl = new URL(process.env.AI_CONTEXT_BASE_URL) + url.host = overridingUrl.host + url.port = overridingUrl.port + url.protocol = overridingUrl.protocol + } + + const res = await fetch(url, { + headers: { + 'user-agent': `NetlifyCLI ${cliVersion}`, + }, + }) + + if (!res.ok) { + return null + } + const contents = await res.text() const minimumCLIVersion = res.headers.get(MINIMUM_CLI_VERSION_HEADER) ?? undefined @@ -99,10 +171,12 @@ export const applyOverrides = (template: string, overrides?: string) => { return template } - return template.replace( - PROVIDER_CONTEXT_OVERRIDES_REGEX, - `<${PROVIDER_CONTEXT_OVERRIDES_TAG}>${overrides}`, - ) + return template + .replace( + PROVIDER_CONTEXT_OVERRIDES_REGEX, + `<${PROVIDER_CONTEXT_OVERRIDES_TAG}>${overrides}`, + ) + .trim() } /** @@ -137,3 +211,84 @@ export const writeFile = async (path: string, contents: string) => { await fs.mkdir(directory, { recursive: true }) await fs.writeFile(path, contents) } + +export const deleteFile = async (path: string) => { + try { + // delete file from file system - not just unlinking it + await fs.rm(path) + } catch { + // ignore + } +} + +export const downloadAndWriteContextFiles = async (consumer: ConsumerConfig, { command }: RunRecipeOptions) => { + await Promise.allSettled( + Object.keys(consumer.contextScopes).map(async (contextKey) => { + const contextConfig = consumer.contextScopes[contextKey] + + const { contents: downloadedFile, minimumCLIVersion } = + (await downloadFile(version, contextConfig, consumer).catch(() => null)) ?? {} + + if (!downloadedFile) { + return logAndThrowError( + `An error occurred when pulling the latest context file for scope ${contextConfig.scope}. Please try again.`, + ) + } + if (minimumCLIVersion && semver.lt(version, minimumCLIVersion)) { + return logAndThrowError( + `This command requires version ${minimumCLIVersion} or above of the Netlify CLI. Refer to ${chalk.underline( + 'https://ntl.fyi/update-cli', + )} for information on how to update.`, + ) + } + + const absoluteFilePath = resolve( + command?.workingDir ?? '', + consumer.path, + `netlify-${contextKey}.${consumer.ext || 'mdc'}`, + ) + + const existing = await getExistingContext(absoluteFilePath) + const remote = parseContextFile(downloadedFile) + + let { contents } = remote + + // Does a file already exist at this path? + if (existing) { + // If it's a file we've created, let's check the version and bail if we're + // already on the latest, otherwise rewrite it with the latest version. + if (existing.provider?.toLowerCase() === NETLIFY_PROVIDER) { + if (remote.version === existing.version) { + log( + `You're all up to date! ${chalk.underline( + absoluteFilePath, + )} contains the latest version of the context files.`, + ) + return + } + + // We must preserve any overrides found in the existing file. + contents = applyOverrides(remote.contents, existing.overrides?.innerContents) + } else { + // Whatever exists in the file goes in the overrides block. + contents = applyOverrides(remote.contents, existing.contents) + } + } + + // we don't want to cut off content, but if we _have_ to + // then we need to do so before writing or the user's + // context gets in a bad state. Note, this can result in + // a file that's not parsable next time. This will be + // fine because the file will simply be replaced. Not ideal + // but solves the issue of a truncated file in a bad state + // being updated. + if (consumer.truncationLimit && contents.length > consumer.truncationLimit) { + contents = contents.slice(0, consumer.truncationLimit) + } + + await writeFile(absoluteFilePath, contents) + + log(`${existing ? 'Updated' : 'Created'} context files at ${chalk.underline(absoluteFilePath)}`) + }), + ) +} diff --git a/src/recipes/ai-context/index.ts b/src/recipes/ai-context/index.ts index d97ff66bf1e..9ae03098d07 100644 --- a/src/recipes/ai-context/index.ts +++ b/src/recipes/ai-context/index.ts @@ -1,47 +1,58 @@ import { resolve } from 'node:path' import inquirer from 'inquirer' -import semver from 'semver' import execa from 'execa' import type { RunRecipeOptions } from '../../commands/recipes/recipes.js' -import { chalk, logAndThrowError, log, version } from '../../utils/command-helpers.js' +import { logAndThrowError, log, version } from '../../utils/command-helpers.js' import { - applyOverrides, - downloadFile, getExistingContext, - parseContextFile, - writeFile, - FILE_NAME, - NETLIFY_PROVIDER, + NTL_DEV_MCP_FILE_NAME, + getContextConsumers, + ConsumerConfig, + deleteFile, + downloadAndWriteContextFiles, } from './context.js' export const description = 'Manage context files for AI tools' -const IDE_RULES_PATH_MAP = { - windsurf: '.windsurf/rules', - cursor: '.cursor/rules', +// context consumers endpoints returns all supported IDE and other consumers +// that can be used to pull context files. It also includes a catchall consumer +// for outlining all context that an unspecified consumer would handle. +const allContextConsumers = (await getContextConsumers(version)).filter((consumer) => !consumer.hideFromCLI) +const cliContextConsumers = allContextConsumers.filter((consumer) => !consumer.hideFromCLI) + +const rulesForDefaultConsumer = allContextConsumers.find((consumer) => consumer.key === 'catchall-consumer') ?? { + key: 'catchall-consumer', + path: './ai-context', + presentedName: '', + ext: 'mdc', + contextScopes: {}, + hideFromCLI: true, } -const presets = [ - { name: 'Windsurf rules (.windsurf/rules/)', value: IDE_RULES_PATH_MAP.windsurf }, - { name: 'Cursor rules (.cursor/rules/)', value: IDE_RULES_PATH_MAP.cursor }, - { name: 'Custom location', value: '' }, -] +const presets = cliContextConsumers.map((consumer) => ({ + name: consumer.presentedName, + value: consumer.key, +})) -const promptForPath = async (): Promise => { - const { presetPath } = await inquirer.prompt([ +// always add the custom location option (not preset from API) +presets.push({ name: 'Custom location', value: rulesForDefaultConsumer.key }) + +const promptForContextConsumerSelection = async (): Promise => { + const { consumerKey } = await inquirer.prompt([ { - name: 'presetPath', + name: 'consumerKey', message: 'Where should we put the context files?', type: 'list', choices: presets, }, ]) - if (presetPath) { - return presetPath + const contextConsumer = consumerKey ? cliContextConsumers.find((consumer) => consumer.key === consumerKey) : null + if (contextConsumer) { + return contextConsumer } const { customPath } = await inquirer.prompt([ @@ -54,40 +65,24 @@ const promptForPath = async (): Promise => { ]) if (customPath) { - return customPath + return { ...rulesForDefaultConsumer, path: customPath || rulesForDefaultConsumer.path } } log('You must select a path.') - return promptForPath() -} - -type IDE = { - name: string - command: string - rulesPath: string + return promptForContextConsumerSelection() } -const IDE: IDE[] = [ - { - name: 'Windsurf', - command: 'windsurf', - rulesPath: IDE_RULES_PATH_MAP.windsurf, - }, - { - name: 'Cursor', - command: 'cursor', - rulesPath: IDE_RULES_PATH_MAP.cursor, - }, -] /** * Checks if a command belongs to a known IDEs by checking if it includes a specific string. * For example, the command that starts windsurf looks something like "/applications/windsurf.app/contents/...". */ -const getIDEFromCommand = (command: string): IDE | null => { +const getConsumerKeyFromCommand = (command: string): string | null => { // The actual command is something like "/applications/windsurf.app/contents/...", but we are only looking for windsurf - const match = IDE.find((ide) => command.includes(ide.command)) - return match ?? null + const match = cliContextConsumers.find( + (consumer) => consumer.consumerProcessCmd && command.includes(consumer.consumerProcessCmd), + ) + return match ? match.key : null } /** @@ -98,7 +93,7 @@ const getCommandAndParentPID = async ( ): Promise<{ parentPID: number command: string - ide: IDE | null + consumerKey: string | null }> => { const { stdout } = await execa('ps', ['-p', String(pid), '-o', 'ppid=,comm=']) const output = stdout.trim() @@ -106,19 +101,19 @@ const getCommandAndParentPID = async ( const parentPID = output.substring(0, spaceIndex) const command = output.substring(spaceIndex + 1).toLowerCase() return { - parentPID: parseInt(parentPID, 10), - command: command, - ide: getIDEFromCommand(command), + parentPID: Number(parentPID), + command, + consumerKey: getConsumerKeyFromCommand(command), } } -const getPathByDetectingIDE = async (): Promise => { +const getPathByDetectingIDE = async (): Promise => { // Go up the chain of ancestor process IDs and find if one of their commands matches an IDE. const ppid = process.ppid let result: Awaited> try { result = await getCommandAndParentPID(ppid) - while (result.parentPID !== 1 && !result.ide) { + while (result.parentPID !== 1 && !result.consumerKey) { result = await getCommandAndParentPID(result.parentPID) } } catch { @@ -126,76 +121,54 @@ const getPathByDetectingIDE = async (): Promise => { // perhaps we are on a machine that doesn't support it. return null } - return result.ide ? result.ide.rulesPath : null + + if (result?.consumerKey) { + const contextConsumer = cliContextConsumers.find((consumer) => consumer.key === result.consumerKey) + if (contextConsumer) { + return contextConsumer + } + } + + return null } -export const run = async ({ args, command }: RunRecipeOptions) => { - // Start the download in the background while we wait for the prompts. - const download = downloadFile(version).catch(() => null) +export const run = async (runOptions: RunRecipeOptions) => { + const { args, command } = runOptions + let consumer: ConsumerConfig | null = null + const filePath: string | null = args[0] - const filePath = - args[0] || - ((process.env.AI_CONTEXT_SKIP_DETECTION === 'true' ? null : await getPathByDetectingIDE()) ?? - (await promptForPath())) - const { contents: downloadedFile, minimumCLIVersion } = (await download) ?? {} + if (filePath) { + consumer = { ...rulesForDefaultConsumer, path: filePath } + } - if (!downloadedFile) { - return logAndThrowError('An error occurred when pulling the latest context files. Please try again.') + if (!consumer && process.env.AI_CONTEXT_SKIP_DETECTION !== 'true') { + consumer = await getPathByDetectingIDE() } - if (minimumCLIVersion && semver.lt(version, minimumCLIVersion)) { - return logAndThrowError( - `This command requires version ${minimumCLIVersion} or above of the Netlify CLI. Refer to ${chalk.underline( - 'https://ntl.fyi/update-cli', - )} for information on how to update.`, - ) + if (!consumer) { + consumer = await promptForContextConsumerSelection() } - const absoluteFilePath = resolve(command?.workingDir ?? '', filePath, FILE_NAME) - const existing = await getExistingContext(absoluteFilePath) - const remote = parseContextFile(downloadedFile) - - let { contents } = remote - - // Does a file already exist at this path? - if (existing) { - // If it's a file we've created, let's check the version and bail if we're - // already on the latest, otherwise rewrite it with the latest version. - if (existing.provider?.toLowerCase() === NETLIFY_PROVIDER) { - if (remote?.version === existing.version) { - log( - `You're all up to date! ${chalk.underline( - absoluteFilePath, - )} contains the latest version of the context files.`, - ) - - return - } - - // We must preserve any overrides found in the existing file. - contents = applyOverrides(remote.contents, existing.overrides?.innerContents) - } else { - // If this is not a file we've created, we can offer to overwrite it and - // preserve the existing contents by moving it to the overrides slot. - const { confirm } = await inquirer.prompt({ - type: 'confirm', - name: 'confirm', - message: `A context file already exists at ${chalk.underline( - absoluteFilePath, - )}. It has not been created by the Netlify CLI, but we can update it while preserving its existing content. Can we proceed?`, - default: true, - }) - - if (!confirm) { - return - } - - // Whatever exists in the file goes in the overrides block. - contents = applyOverrides(remote.contents, existing.contents) - } + if (!consumer?.contextScopes) { + log( + 'No context files found for this consumer. Try again or let us know if this happens again via our support channels.', + ) + return } - await writeFile(absoluteFilePath, contents) + try { + await downloadAndWriteContextFiles(consumer, runOptions) + + // the deprecated MCP file path + // let's remove that file if it exists. + const priorContextFilePath = resolve(command?.workingDir ?? '', consumer.path, NTL_DEV_MCP_FILE_NAME) + const priorExists = await getExistingContext(priorContextFilePath) + if (priorExists) { + await deleteFile(priorContextFilePath) + } - log(`${existing ? 'Updated' : 'Created'} context files at ${chalk.underline(absoluteFilePath)}`) + log('All context files have been added!') + } catch (error) { + logAndThrowError(error) + } } diff --git a/tests/unit/recipes/ai-context/context.test.ts b/tests/unit/recipes/ai-context/context.test.ts index e971a1599fd..5153ba6981f 100644 --- a/tests/unit/recipes/ai-context/context.test.ts +++ b/tests/unit/recipes/ai-context/context.test.ts @@ -1,40 +1,35 @@ -import { describe, expect, test } from 'vitest' - +import { describe, test, expect } from 'vitest' import { applyOverrides, parseContextFile } from '../../../../src/recipes/ai-context/context.js' describe('applyOverrides', () => { test('applies overrides to a context file', () => { - const file = ` - sdf - This is the contents - Something here - Something there - ` - const expected = ` - Here come the overrides - This is the contents - Something here - Something there - ` + const file = `sdf +This is the contents + Something here + Something there +` + const expected = `Here come the overrides +This is the contents + Something here + Something there +` expect(applyOverrides(file, 'Here come the overrides')).toBe(expected) }) test('supports a multiline overrides slot', () => { - const file = ` - - This is where overrides go - - This is the contents - Something here - Something there - ` - const expected = ` - Here come the overrides - This is the contents - Something here - Something there - ` + const file = ` + This is where overrides go + +This is the contents + Something here + Something there +` + const expected = `Here come the overrides +This is the contents + Something here + Something there +` expect(applyOverrides(file, 'Here come the overrides')).toBe(expected) }) @@ -77,20 +72,20 @@ describe('parseContextFile', () => { test('extracts overrides', () => { const overrides = `This will be kept` const file = ` - ${overrides} - This is the contents - Something here - Something there - ` +${overrides} +This is the contents +Something here +Something there +` expect(parseContextFile(file)).toStrictEqual({ provider: 'Netlify', version: '1.0', contents: file, innerContents: `This is the contents - Something here - Something there - `, +Something here +Something there +`, overrides: { contents: overrides, innerContents: 'This will be kept', diff --git a/tests/unit/recipes/ai-context/download-context-files.test.ts b/tests/unit/recipes/ai-context/download-context-files.test.ts new file mode 100644 index 00000000000..e7e5e969567 --- /dev/null +++ b/tests/unit/recipes/ai-context/download-context-files.test.ts @@ -0,0 +1,229 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ + +import { vi, describe, test, expect, beforeEach, afterEach } from 'vitest' +import { resolve } from 'node:path' +import type { ConsumerConfig } from '../../../../src/recipes/ai-context/context.js' +import type { RunRecipeOptions } from '../../../../src/commands/recipes/recipes.js' + +// Mock fs module +vi.mock('node:fs', () => { + return { + promises: { + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + stat: vi.fn().mockImplementation(() => { + const err = new Error('File not found') as NodeJS.ErrnoException + err.code = 'ENOENT' + throw err + }), + readFile: vi.fn(), + rm: vi.fn().mockResolvedValue(undefined), + }, + } +}) + +// Set up global fetch mock +const mockFetch = vi.fn() +const originalFetch = globalThis.fetch + +// Mock command helpers +vi.mock('../../../../src/utils/command-helpers.js', () => { + const log = vi.fn() + const logAndThrowError = vi.fn((msg) => { + throw new Error(msg as string) + }) + + return { + log, + logAndThrowError, + chalk: { underline: (text: string) => text }, + version: '1.0.0', + } +}) + +// Import modules after mocks are defined +import { promises as fs } from 'node:fs' +import { downloadAndWriteContextFiles } from '../../../../src/recipes/ai-context/context.js' + +describe('downloadAndWriteContextFiles', () => { + // Setup test data + const mockConsumer: ConsumerConfig = { + key: 'test-consumer', + presentedName: 'Test Consumer', + path: './test-path', + ext: 'mdc', + contextScopes: { + serverless: { + scope: 'Serverless functions', + shared: ['shared/compute-globals'], + endpoint: 'https://docs.netlify.com/ai-context/scoped-context?scopes=serverless', + }, + 'edge-functions': { + scope: 'Edge functions', + shared: ['shared/compute-globals'], + endpoint: 'https://docs.netlify.com/ai-context/scoped-context?scopes=edge-functions', + }, + }, + } + + const mockRunOptions = { + command: { + workingDir: '/test/dir', + }, + } as RunRecipeOptions + + const mockProviderContent = 'Test content' + + const fetchRespImpl = { + ok: true, + text: () => Promise.resolve(), + json: () => Promise.resolve(), + headers: { get: (header: string) => (header === 'x-cli-min-ver' ? '0.5.0' : null) }, + } + + beforeEach(() => { + vi.clearAllMocks() + globalThis.fetch = mockFetch + mockFetch.mockResolvedValue({ + ...fetchRespImpl, + text: () => Promise.resolve(mockProviderContent), + json: () => Promise.resolve({ consumers: [] }), + }) + }) + + afterEach(() => { + globalThis.fetch = originalFetch + vi.clearAllMocks() + }) + + test('downloads and writes context files for all scopes', async () => { + // Execute the actual function + await downloadAndWriteContextFiles(mockConsumer, mockRunOptions) + + // Verify expected calls + expect(mockFetch).toHaveBeenCalledTimes(2) // Once for each scope + expect(fs.writeFile).toHaveBeenCalledTimes(2) // Once for each scope + + // Verify file paths and content + expect(fs.writeFile).toHaveBeenCalledWith( + resolve(mockRunOptions.command?.workingDir ?? '', 'test-path', 'netlify-serverless.mdc'), + mockProviderContent, + ) + expect(fs.writeFile).toHaveBeenCalledWith( + resolve(mockRunOptions.command?.workingDir ?? '', 'test-path', 'netlify-edge-functions.mdc'), + mockProviderContent, + ) + }) + + test('handles existing files with same version', async () => { + // Mock existing file with same version + // + // @ts-expect-error mocking is not 100% consistent with full API and types for + fs.stat.mockResolvedValue({ isFile: () => true } as () => boolean) + // @ts-expect-error mocking is not 100% consistent with full API and types for + fs.readFile.mockResolvedValue(mockProviderContent) + + // Execute the actual function + await downloadAndWriteContextFiles(mockConsumer, mockRunOptions) + + // Verify expected behavior - no writes when versions match + expect(fs.writeFile).not.toHaveBeenCalled() + }) + + test('applies overrides when updating existing Netlify files', async () => { + // Mock existing file with different version + const existingContent = + 'Old contentCustom overrides' + // @ts-expect-error mocking is not 100% consistent with full API and types for + fs.stat.mockResolvedValue({ isFile: () => true } as () => boolean) + // @ts-expect-error mocking is not 100% consistent with full API and types for + fs.readFile.mockResolvedValue(existingContent) + + // Execute the actual function + await downloadAndWriteContextFiles(mockConsumer, mockRunOptions) + + // Verify file was updated with overrides applied + expect(fs.writeFile).toHaveBeenCalledTimes(2) + }) + + test('handles truncation when content exceeds limit', async () => { + // Create long content + const longContent = + '' + + Math.random().toString().repeat(1000) + + '' + mockFetch.mockResolvedValue({ + ...fetchRespImpl, + text: () => Promise.resolve(longContent), + json: () => Promise.resolve({ consumers: [] }), + }) + + // Add truncation limit to consumer + const consumerWithLimit = { + ...mockConsumer, + truncationLimit: 100, + } + + // Execute the actual function + await downloadAndWriteContextFiles(consumerWithLimit, mockRunOptions) + + // Verify content was truncated + const writeFileCalls = vi.mocked(fs.writeFile).mock.calls + expect(writeFileCalls.length).toBeGreaterThan(0) + + // Check that all written content is truncated + writeFileCalls.forEach((call) => { + // @ts-expect-error mocking is not 100% consistent with full API and types for + expect(call[1].length).toBeLessThanOrEqual(100) + }) + }) + + test('uses custom file extension when specified', async () => { + // Create consumer with custom extension + const consumerWithCustomExt = { + ...mockConsumer, + ext: 'json', // Custom extension instead of default 'mdc' + } + + // Execute the actual function + await downloadAndWriteContextFiles(consumerWithCustomExt, mockRunOptions) + + // Verify file paths have the custom extension + expect(fs.writeFile).toHaveBeenCalledWith( + resolve(mockRunOptions.command?.workingDir ?? '', 'test-path', 'netlify-serverless.json'), + mockProviderContent, + ) + expect(fs.writeFile).toHaveBeenCalledWith( + resolve(mockRunOptions.command?.workingDir ?? '', 'test-path', 'netlify-edge-functions.json'), + mockProviderContent, + ) + }) + + test('handles download errors gracefully', async () => { + // Mock fetch to return not ok + // @ts-expect-error mocking is not 100% consistent with full API and types for + fetch.mockResolvedValue({ + ok: false, + } as Response) + + // Execute the actual function and expect error + await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).resolves.toBeUndefined() + }) + + test('checks CLI version compatibility', async () => { + // Set higher minimum CLI version + // @ts-expect-error mocking is not 100% consistent with full API and types for + fetch.mockResolvedValue({ + ok: true, + headers: { + get: (header: string) => { + if (header === 'x-cli-min-ver') return '2.0.0' // Higher than the mocked current version + return null + }, + }, + } as Response) + + // Execute the actual function and expect error + await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).resolves.toBeUndefined() + }) +})