Skip to content

feat: support granular context file setting #7284

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking β€œSign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
May 14, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 164 additions & 9 deletions src/recipes/ai-context/context.ts
Original file line number Diff line number Diff line change
@@ -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 ([^>]*)>(.*)<\/providercontext>/ims
const PROVIDER_CONTEXT_OVERRIDES_REGEX = /<providercontextoverrides([^>]*)>(.*)<\/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<string, ContextConfig>
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}</${PROVIDER_CONTEXT_OVERRIDES_TAG}>`,
)
return template
.replace(
PROVIDER_CONTEXT_OVERRIDES_REGEX,
`<${PROVIDER_CONTEXT_OVERRIDES_TAG}>${overrides}</${PROVIDER_CONTEXT_OVERRIDES_TAG}>`,
)
.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)}`)
}),
)
}
195 changes: 84 additions & 111 deletions src/recipes/ai-context/index.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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<ConsumerConfig> => {
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<string> => {
])

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,104 +93,82 @@ 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='])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens on Windows?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know, @smnh will need to verify. Currently, this work is all wrapped in try/catch and will skip if any errors are thrown and, if it simply doesn't work, then it will also continue as it does without this logic.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eduardoboucas on Windows and on systems that do not support ps it will fallback to IDE selection.
We can add later detection for OS and run appropriate command that lists processes with their IDs and commands.

const output = stdout.trim()
const spaceIndex = output.indexOf(' ')
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<string | null> => {
const getPathByDetectingIDE = async (): Promise<ConsumerConfig | null> => {
// 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<ReturnType<typeof getCommandAndParentPID>>
try {
result = await getCommandAndParentPID(ppid)
while (result.parentPID !== 1 && !result.ide) {
while (result.parentPID !== 1 && !result.consumerKey) {
result = await getCommandAndParentPID(result.parentPID)
}
} catch {
// The command "ps -p {pid} -o ppid=,comm=" didn't work,
// 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)
}
}
67 changes: 31 additions & 36 deletions tests/unit/recipes/ai-context/context.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<ProviderContextOverrides>sdf</ProviderContextOverrides>
<ProviderContext version="1.0" provider="Netlify">This is the contents
Something here
Something there
</ProviderContext>`
const expected = `
<ProviderContextOverrides>Here come the overrides</ProviderContextOverrides>
<ProviderContext version="1.0" provider="Netlify">This is the contents
Something here
Something there
</ProviderContext>`
const file = `<ProviderContextOverrides>sdf</ProviderContextOverrides>
<ProviderContext version="1.0" provider="Netlify">This is the contents
Something here
Something there
</ProviderContext>`
const expected = `<ProviderContextOverrides>Here come the overrides</ProviderContextOverrides>
<ProviderContext version="1.0" provider="Netlify">This is the contents
Something here
Something there
</ProviderContext>`

expect(applyOverrides(file, 'Here come the overrides')).toBe(expected)
})

test('supports a multiline overrides slot', () => {
const file = `
<ProviderContextOverrides>
This is where overrides go
</ProviderContextOverrides>
<ProviderContext version="1.0" provider="Netlify">This is the contents
Something here
Something there
</ProviderContext>`
const expected = `
<ProviderContextOverrides>Here come the overrides</ProviderContextOverrides>
<ProviderContext version="1.0" provider="Netlify">This is the contents
Something here
Something there
</ProviderContext>`
const file = `<ProviderContextOverrides>
This is where overrides go
</ProviderContextOverrides>
<ProviderContext version="1.0" provider="Netlify">This is the contents
Something here
Something there
</ProviderContext>`
const expected = `<ProviderContextOverrides>Here come the overrides</ProviderContextOverrides>
<ProviderContext version="1.0" provider="Netlify">This is the contents
Something here
Something there
</ProviderContext>`

expect(applyOverrides(file, 'Here come the overrides')).toBe(expected)
})
@@ -77,20 +72,20 @@ describe('parseContextFile', () => {
test('extracts overrides', () => {
const overrides = `<ProviderContextOverrides>This will be kept</ProviderContextOverrides>`
const file = `
${overrides}
<ProviderContext version="1.0" provider="Netlify">This is the contents
Something here
Something there
</ProviderContext>`
${overrides}
<ProviderContext version="1.0" provider="Netlify">This is the contents
Something here
Something there
</ProviderContext>`

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',
229 changes: 229 additions & 0 deletions tests/unit/recipes/ai-context/download-context-files.test.ts
Original file line number Diff line number Diff line change
@@ -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 = '<ProviderContext version="1.0" provider="Netlify">Test content</ProviderContext>'

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 =
'<ProviderContext version="0.9" provider="Netlify">Old content<ProviderContextOverrides>Custom overrides</ProviderContextOverrides></ProviderContext>'
// @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 =
'<ProviderContext version="1.0" provider="Netlify">' +
Math.random().toString().repeat(1000) +
'</ProviderContext>'
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()
})
})