2313 lines
76 KiB
TypeScript
2313 lines
76 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest'
|
|
import { z } from '@bpinternal/zui'
|
|
|
|
import { getClient, getZai, metadata } from './utils'
|
|
import { TableAdapter } from '../src/adapters/botpress-table'
|
|
|
|
type File = {
|
|
path: string
|
|
name: string
|
|
content: string
|
|
}
|
|
|
|
const simpleFile: File = {
|
|
path: 'src/hello.ts',
|
|
name: 'hello.ts',
|
|
content: `function greet(name: string) {
|
|
console.log('Hello, ' + name)
|
|
}
|
|
|
|
greet('World')`,
|
|
}
|
|
|
|
const configFile: File = {
|
|
path: 'config.json',
|
|
name: 'config.json',
|
|
content: `{
|
|
"version": "1.0.0",
|
|
"name": "test-app",
|
|
"enabled": true
|
|
}`,
|
|
}
|
|
|
|
const packageFile: File = {
|
|
path: 'package.json',
|
|
name: 'package.json',
|
|
content: `{
|
|
"name": "my-app",
|
|
"version": "0.1.0",
|
|
"dependencies": {
|
|
"react": "^18.0.0"
|
|
}
|
|
}`,
|
|
}
|
|
|
|
describe('zai.patch', { timeout: 60_000 }, () => {
|
|
const zai = getZai()
|
|
|
|
it('patches a single file with simple instruction and verifies syntax', async () => {
|
|
const result = await zai.patch([simpleFile], 'change the greeting message to say "Hi" instead of "Hello"').result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
expect(result.output[0].path).toBe('src/hello.ts')
|
|
expect(result.output[0].name).toBe('hello.ts')
|
|
expect(result.output[0].content).toContain('Hi')
|
|
expect(result.output[0].content).not.toContain('Hello')
|
|
expect(result.output[0].content).toContain('greet')
|
|
|
|
expect(result.output[0].content).toMatchInlineSnapshot(`
|
|
"function greet(name: string) {
|
|
console.log('Hi, ' + name)
|
|
}
|
|
|
|
greet('World')"
|
|
`)
|
|
expect(result.output[0].patch).toMatchInlineSnapshot(`"◼︎=2| console.log('Hi, ' + name)"`)
|
|
})
|
|
|
|
it('patches a single file by adding a line', async () => {
|
|
const result = await zai
|
|
.patch(
|
|
[simpleFile],
|
|
'add a complete JSDoc comment block (/** ... */) above the greet function that describes what it does'
|
|
)
|
|
.result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
expect(result.output[0].content).toContain('/**')
|
|
expect(result.output[0].content).toContain('*/')
|
|
expect(result.output[0].content).toContain('function greet')
|
|
|
|
expect(result.output[0].content).toMatchInlineSnapshot(`
|
|
"/**
|
|
* Greets the provided name.
|
|
*/
|
|
function greet(name: string) {
|
|
console.log('Hello, ' + name)
|
|
}
|
|
|
|
greet('World')"
|
|
`)
|
|
expect(result.output[0].patch).toMatchInlineSnapshot(`
|
|
"◼︎=1|/**
|
|
* Greets the provided name.
|
|
*/
|
|
function greet(name: string) {"
|
|
`)
|
|
})
|
|
|
|
it('patches a single file by deleting a line', async () => {
|
|
const result = await zai.patch([simpleFile], 'remove the greet function call at the bottom').result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
expect(result.output[0].content).toContain('function greet')
|
|
expect(result.output[0].content).not.toContain("greet('World')")
|
|
|
|
expect(result.output[0].content).toMatchInlineSnapshot(`
|
|
"function greet(name: string) {
|
|
console.log('Hello, ' + name)
|
|
}
|
|
"
|
|
`)
|
|
expect(result.output[0].patch).toMatchInlineSnapshot(`"◼︎-005"`)
|
|
})
|
|
|
|
it('patches multiple files with a single instruction', async () => {
|
|
const files = [configFile, packageFile]
|
|
const result = await zai.patch(files, 'change version to 2.0.0 in all files').result()
|
|
|
|
expect(result.output).toHaveLength(2)
|
|
expect(result.output.find((f) => f.name === 'config.json')?.content).toContain('"version": "2.0.0"')
|
|
expect(result.output.find((f) => f.name === 'package.json')?.content).toContain('"version": "2.0.0"')
|
|
|
|
expect(result.output.find((f) => f.name === 'config.json')?.patch).toMatchInlineSnapshot(
|
|
`"◼︎=2| "version": "2.0.0","`
|
|
)
|
|
expect(result.output.find((f) => f.name === 'package.json')?.patch).toMatchInlineSnapshot(
|
|
`"◼︎=3| "version": "2.0.0","`
|
|
)
|
|
})
|
|
|
|
it('patches JSON file correctly', async () => {
|
|
const result = await zai
|
|
.patch([configFile], 'add a new field "description": "A test application" after the name field')
|
|
.result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
expect(result.output[0].content).toContain('"description": "A test application"')
|
|
expect(result.output[0].content).toContain('"name": "test-app"')
|
|
// Verify it's still valid JSON structure
|
|
expect(() => JSON.parse(result.output[0].content)).not.toThrow()
|
|
|
|
expect(result.output[0].content).toMatchInlineSnapshot(`
|
|
"{
|
|
"version": "1.0.0",
|
|
"name": "test-app",
|
|
"description": "A test application",
|
|
"enabled": true
|
|
}"
|
|
`)
|
|
expect(result.output[0].patch).toMatchInlineSnapshot(`"◼︎>003| "description": "A test application","`)
|
|
})
|
|
|
|
it('handles complex multi-line changes', async () => {
|
|
const result = await zai
|
|
.patch(
|
|
[simpleFile],
|
|
'refactor the greet function to accept an optional greeting parameter that defaults to "Hello"'
|
|
)
|
|
.result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
expect(result.output[0].content).toContain('greet')
|
|
// Should have parameter with default or optional parameter
|
|
const hasDefaultParam =
|
|
result.output[0].content.includes('greeting = ') ||
|
|
result.output[0].content.includes('greeting?') ||
|
|
result.output[0].content.includes('greeting: string = ')
|
|
expect(hasDefaultParam).toBe(true)
|
|
|
|
expect(result.output[0].content).toMatchInlineSnapshot(`
|
|
"function greet(name: string, greeting: string = "Hello") {
|
|
console.log(greeting + ', ' + name)
|
|
}
|
|
|
|
greet('World')"
|
|
`)
|
|
expect(result.output[0].patch).toMatchInlineSnapshot(`
|
|
"◼︎=001|function greet(name: string, greeting: string = "Hello") {
|
|
◼︎=002| console.log(greeting + ', ' + name)"
|
|
`)
|
|
})
|
|
|
|
it('leaves files unchanged when instruction says no changes needed', async () => {
|
|
const result = await zai.patch([simpleFile], 'only modify files that use React hooks').result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
// File should be mostly unchanged since it doesn't use React
|
|
expect(result.output[0].path).toBe(simpleFile.path)
|
|
|
|
expect(result.output[0].patch).toMatchInlineSnapshot(`""`)
|
|
})
|
|
|
|
it('handles empty files array', async () => {
|
|
const result = await zai.patch([], 'change something').result()
|
|
|
|
expect(result.output).toHaveLength(0)
|
|
})
|
|
|
|
it('preserves file metadata (path and name)', async () => {
|
|
const files = [
|
|
{ path: 'src/components/Button.tsx', name: 'Button.tsx', content: 'export const Button = () => null' },
|
|
{ path: 'src/utils/helper.ts', name: 'helper.ts', content: 'export const helper = () => {}' },
|
|
]
|
|
|
|
const result = await zai.patch(files, 'add a comment at the top of each file').result()
|
|
|
|
expect(result.output).toHaveLength(2)
|
|
expect(result.output.find((f) => f.path === 'src/components/Button.tsx')).toBeDefined()
|
|
expect(result.output.find((f) => f.path === 'src/utils/helper.ts')).toBeDefined()
|
|
expect(result.output.find((f) => f.name === 'Button.tsx')).toBeDefined()
|
|
expect(result.output.find((f) => f.name === 'helper.ts')).toBeDefined()
|
|
|
|
expect(result.output.find((f) => f.name === 'Button.tsx')?.patch).toMatchInlineSnapshot(`"◼︎<001|//"`)
|
|
expect(result.output.find((f) => f.name === 'helper.ts')?.patch).toMatchInlineSnapshot(`"◼︎<001|//"`)
|
|
})
|
|
|
|
it('handles file with special characters in content', async () => {
|
|
const specialFile: File = {
|
|
path: 'test.md',
|
|
name: 'test.md',
|
|
content: `# Title\n\nContent with \`code\` and **bold** and [links](url)`,
|
|
}
|
|
|
|
const result = await zai.patch([specialFile], 'add a new section called "## Installation"').result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
expect(result.output[0].content).toContain('## Installation')
|
|
expect(result.output[0].content).toContain('# Title')
|
|
|
|
expect(result.output[0].content).toMatchInlineSnapshot(`
|
|
"# Title
|
|
|
|
Content with \`code\` and **bold** and [links](url)
|
|
## Installation"
|
|
`)
|
|
expect(result.output[0].patch).toMatchInlineSnapshot(`"◼︎>3|## Installation"`)
|
|
})
|
|
|
|
it('patches a markdown table by adding a row', async () => {
|
|
const tableFile: File = {
|
|
path: 'fruits.md',
|
|
name: 'fruits.md',
|
|
content: `# Fruits
|
|
|
|
| Name | Color | Price |
|
|
| ------ | ------ | ----- |
|
|
| Apple | Red | 1.00 |
|
|
| Banana | Yellow | 0.50 |`,
|
|
}
|
|
|
|
const result = await zai
|
|
.patch([tableFile], 'add a new row to the table for "Grape" with color "Purple" and price "2.00"')
|
|
.result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
const patched = result.output[0].content
|
|
|
|
// Grape row exists and is well-formed (no doubled pipes like "||")
|
|
expect(patched).toContain('Grape')
|
|
expect(patched).toContain('Purple')
|
|
expect(patched).toContain('2.00')
|
|
expect(patched).not.toMatch(/\|\|/)
|
|
|
|
// Existing rows are preserved
|
|
expect(patched).toContain('| Apple | Red | 1.00 |')
|
|
expect(patched).toContain('| Banana | Yellow | 0.50 |')
|
|
|
|
// Every table row starts with a single pipe (not a double pipe)
|
|
const tableRows = patched.split('\n').filter((l) => l.trim().startsWith('|'))
|
|
for (const row of tableRows) {
|
|
expect(row.trimStart().startsWith('||')).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('patches a markdown table by changing an existing row', async () => {
|
|
const tableFile: File = {
|
|
path: 'fruits.md',
|
|
name: 'fruits.md',
|
|
content: `# Fruits
|
|
|
|
| Name | Color | Price |
|
|
| ------ | ------ | ----- |
|
|
| Apple | Red | 1.00 |
|
|
| Banana | Yellow | 0.50 |`,
|
|
}
|
|
|
|
const result = await zai.patch([tableFile], 'change the price of Apple from 1.00 to 1.50').result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
const patched = result.output[0].content
|
|
|
|
expect(patched).toContain('1.50')
|
|
expect(patched).not.toContain('| Apple | Red | 1.00 |')
|
|
|
|
// No doubled pipes — guards against a bug where lines starting with `|`
|
|
// get an extra leading pipe added by the patch protocol.
|
|
expect(patched).not.toMatch(/\|\|/)
|
|
|
|
// Banana row is untouched
|
|
expect(patched).toContain('| Banana | Yellow | 0.50 |')
|
|
|
|
// Every table row starts with a single pipe
|
|
const tableRows = patched.split('\n').filter((l) => l.trim().startsWith('|'))
|
|
for (const row of tableRows) {
|
|
expect(row.trimStart().startsWith('||')).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('can add multiple sections to different files', async () => {
|
|
const file1: File = { path: 'file1.txt', name: 'file1.txt', content: 'Line 1\nLine 2' }
|
|
const file2: File = { path: 'file2.txt', name: 'file2.txt', content: 'Content A\nContent B' }
|
|
|
|
const result = await zai.patch([file1, file2], 'add "# Header" at the beginning of each file').result()
|
|
|
|
expect(result.output).toHaveLength(2)
|
|
expect(result.output[0].content).toContain('# Header')
|
|
expect(result.output[1].content).toContain('# Header')
|
|
|
|
expect(result.output[0].patch).toMatchInlineSnapshot(`"◼︎<1|# Header"`)
|
|
expect(result.output[1].patch).toMatchInlineSnapshot(`"◼︎<1|# Header"`)
|
|
})
|
|
|
|
it('replaces a range of lines', async () => {
|
|
const multiLineFile: File = {
|
|
path: 'example.ts',
|
|
name: 'example.ts',
|
|
content: `const a = 1
|
|
const b = 2
|
|
const c = 3
|
|
const d = 4
|
|
const e = 5`,
|
|
}
|
|
|
|
const result = await zai
|
|
.patch(
|
|
[multiLineFile],
|
|
'replace lines 2 to 4 (const b through const d) with a single line: const sum = 2 + 3 + 4'
|
|
)
|
|
.result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
expect(result.output[0].content).toContain('const sum')
|
|
expect(result.output[0].content).toContain('const a = 1')
|
|
expect(result.output[0].content).toContain('const e = 5')
|
|
expect(result.output[0].content).not.toContain('const b = 2')
|
|
expect(result.output[0].content).not.toContain('const c = 3')
|
|
expect(result.output[0].content).not.toContain('const d = 4')
|
|
|
|
expect(result.output[0].content).toMatchInlineSnapshot(`
|
|
"const a = 1
|
|
const sum = 2 + 3 + 4
|
|
const e = 5"
|
|
`)
|
|
expect(result.output[0].patch).toMatchInlineSnapshot(`"◼︎=2-4|const sum = 2 + 3 + 4"`)
|
|
})
|
|
|
|
it('ensures patched JSON is valid JSON', async () => {
|
|
const jsonFile: File = {
|
|
path: 'data.json',
|
|
name: 'data.json',
|
|
content: `{
|
|
"users": [
|
|
{"id": 1, "name": "Alice"},
|
|
{"id": 2, "name": "Bob"}
|
|
],
|
|
"count": 2
|
|
}`,
|
|
}
|
|
|
|
const result = await zai
|
|
.patch([jsonFile], 'add a new user {"id": 3, "name": "Charlie"} to the users array and update count to 3')
|
|
.result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
// Verify it's valid JSON
|
|
expect(() => JSON.parse(result.output[0].content)).not.toThrow()
|
|
const parsed = JSON.parse(result.output[0].content)
|
|
expect(parsed.users).toHaveLength(3)
|
|
expect(parsed.count).toBe(3)
|
|
expect(parsed.users[2].name).toBe('Charlie')
|
|
|
|
expect(result.output[0].content).toMatchInlineSnapshot(`
|
|
"{
|
|
"users": [
|
|
{"id": 1, "name": "Alice"},
|
|
{"id": 2, "name": "Bob"},
|
|
{"id": 3, "name": "Charlie"}
|
|
],
|
|
"count": 3
|
|
}"
|
|
`)
|
|
expect(result.output[0].patch).toMatchInlineSnapshot(`
|
|
"◼︎=4| {"id": 2, "name": "Bob"},
|
|
◼︎<5| {"id": 3, "name": "Charlie"}
|
|
◼︎=6| "count": 3"
|
|
`)
|
|
})
|
|
|
|
it('handles large file with chunking', async () => {
|
|
// Create a large file with many lines (but not TOO large)
|
|
const largeContent = Array.from({ length: 1000 }, (_, i) => `const var${i} = ${i}`).join('\n')
|
|
const largeFile: File = {
|
|
path: 'large.ts',
|
|
name: 'large.ts',
|
|
content: largeContent,
|
|
}
|
|
|
|
const result = await zai.patch([largeFile], 'add a comment "// Variables" at the very top').result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
expect(result.output[0].content).toContain('// Variables')
|
|
expect(result.output[0].content).toContain('const var0 = 0')
|
|
|
|
expect(result.output[0].patch).toMatchInlineSnapshot(`"◼︎<1|// Variables"`)
|
|
})
|
|
|
|
it('patches code that requires understanding context from two files', async () => {
|
|
const apiFile: File = {
|
|
path: 'src/api.ts',
|
|
name: 'api.ts',
|
|
content: `export interface User {
|
|
id: number
|
|
name: string
|
|
}
|
|
|
|
export function getUser(id: number): User {
|
|
return { id, name: 'Unknown' }
|
|
}`,
|
|
}
|
|
|
|
const componentFile: File = {
|
|
path: 'src/UserComponent.tsx',
|
|
name: 'UserComponent.tsx',
|
|
content: `import { getUser } from './api'
|
|
|
|
export function UserComponent({ userId }: { userId: number }) {
|
|
const user = getUser(userId)
|
|
return <div>{user.name}</div>
|
|
}`,
|
|
}
|
|
|
|
const result = await zai
|
|
.patch(
|
|
[apiFile, componentFile],
|
|
'add an email field to the User interface and update the component to display it'
|
|
)
|
|
.result()
|
|
|
|
expect(result.output).toHaveLength(2)
|
|
const patchedApi = result.output.find((f) => f.name === 'api.ts')
|
|
const patchedComponent = result.output.find((f) => f.name === 'UserComponent.tsx')
|
|
|
|
// API should have email field
|
|
expect(patchedApi?.content).toContain('email')
|
|
// Component should display email
|
|
expect(patchedComponent?.content).toContain('email')
|
|
|
|
expect(patchedApi?.content).toMatchInlineSnapshot(`
|
|
"export interface User {
|
|
id: number
|
|
name: string
|
|
email: string
|
|
}
|
|
|
|
export function getUser(id: number): User {
|
|
return { id, name: 'Unknown', email: 'unknown@example.com' }
|
|
}"
|
|
`)
|
|
expect(patchedApi?.patch).toMatchInlineSnapshot(`
|
|
"◼︎>003| email: string
|
|
◼︎=007| return { id, name: 'Unknown', email: 'unknown@example.com' }"
|
|
`)
|
|
expect(patchedComponent?.content).toMatchInlineSnapshot(`
|
|
"import { getUser } from './api'
|
|
|
|
export function UserComponent({ userId }: { userId: number }) {
|
|
const user = getUser(userId)
|
|
return <div>{user.name} ({user.email})</div>
|
|
}"
|
|
`)
|
|
expect(patchedComponent?.patch).toMatchInlineSnapshot(`"◼︎=005| return <div>{user.name} ({user.email})</div>"`)
|
|
})
|
|
|
|
it('refactors two files in a related way (rename function and update usage)', async () => {
|
|
const utilsFile: File = {
|
|
path: 'src/utils.ts',
|
|
name: 'utils.ts',
|
|
content: `export function calculateTotal(items: number[]): number {
|
|
return items.reduce((sum, item) => sum + item, 0)
|
|
}`,
|
|
}
|
|
|
|
const appFile: File = {
|
|
path: 'src/app.ts',
|
|
name: 'app.ts',
|
|
content: `import { calculateTotal } from './utils'
|
|
|
|
const prices = [10, 20, 30]
|
|
const total = calculateTotal(prices)
|
|
console.log(total)`,
|
|
}
|
|
|
|
const result = await zai
|
|
.patch([utilsFile, appFile], 'rename the calculateTotal function to computeSum and update all usages')
|
|
.result()
|
|
|
|
expect(result.output).toHaveLength(2)
|
|
const patchedUtils = result.output.find((f) => f.name === 'utils.ts')
|
|
const patchedApp = result.output.find((f) => f.name === 'app.ts')
|
|
|
|
// Utils should have new name
|
|
expect(patchedUtils?.content).toContain('computeSum')
|
|
expect(patchedUtils?.content).not.toContain('calculateTotal')
|
|
// App should import and use new name
|
|
expect(patchedApp?.content).toContain('computeSum')
|
|
expect(patchedApp?.content).not.toContain('calculateTotal')
|
|
|
|
expect(patchedUtils?.content).toMatchInlineSnapshot(`
|
|
"export function computeSum(items: number[]): number {
|
|
return items.reduce((sum, item) => sum + item, 0)
|
|
}"
|
|
`)
|
|
expect(patchedUtils?.patch).toMatchInlineSnapshot(`"◼︎=001|export function computeSum(items: number[]): number {"`)
|
|
expect(patchedApp?.content).toMatchInlineSnapshot(`
|
|
"import { computeSum } from './utils'
|
|
|
|
const prices = [10, 20, 30]
|
|
const total = computeSum(prices)
|
|
console.log(total)"
|
|
`)
|
|
expect(patchedApp?.patch).toMatchInlineSnapshot(`
|
|
"◼︎=001|import { computeSum } from './utils'
|
|
◼︎=004|const total = computeSum(prices)"
|
|
`)
|
|
})
|
|
|
|
it('leaves files unchanged when they do not need changes (5 files, only 2 need changes)', async () => {
|
|
const asyncFile1: File = {
|
|
path: 'src/api1.ts',
|
|
name: 'api1.ts',
|
|
content: `export async function fetchUser(id: number) {
|
|
const response = await fetch('/api/user/' + id)
|
|
return response.json()
|
|
}`,
|
|
}
|
|
|
|
const asyncFile2: File = {
|
|
path: 'src/api2.ts',
|
|
name: 'api2.ts',
|
|
content: `export async function saveData(data: any) {
|
|
const response = await fetch('/api/save', { method: 'POST', body: JSON.stringify(data) })
|
|
return response.json()
|
|
}`,
|
|
}
|
|
|
|
const syncFile1: File = {
|
|
path: 'src/utils.ts',
|
|
name: 'utils.ts',
|
|
content: `export function formatName(first: string, last: string) {
|
|
return first + ' ' + last
|
|
}`,
|
|
}
|
|
|
|
const syncFile2: File = {
|
|
path: 'src/constants.ts',
|
|
name: 'constants.ts',
|
|
content: `export const API_URL = 'https://api.example.com'
|
|
export const TIMEOUT = 5000`,
|
|
}
|
|
|
|
const configFile: File = {
|
|
path: 'config.json',
|
|
name: 'config.json',
|
|
content: `{
|
|
"name": "app",
|
|
"version": "1.0.0"
|
|
}`,
|
|
}
|
|
|
|
const files = [asyncFile1, asyncFile2, syncFile1, syncFile2, configFile]
|
|
const result = await zai
|
|
.patch(files, 'add try-catch error handling to all async functions that call fetch')
|
|
.result()
|
|
|
|
expect(result.output).toHaveLength(5)
|
|
|
|
// Find the patched files
|
|
const patchedApi1 = result.output.find((f) => f.name === 'api1.ts')
|
|
const patchedApi2 = result.output.find((f) => f.name === 'api2.ts')
|
|
const patchedUtils = result.output.find((f) => f.name === 'utils.ts')
|
|
const patchedConstants = result.output.find((f) => f.name === 'constants.ts')
|
|
const patchedConfig = result.output.find((f) => f.name === 'config.json')
|
|
|
|
// Async files should have try-catch
|
|
expect(patchedApi1?.content).toContain('try')
|
|
expect(patchedApi1?.content).toContain('catch')
|
|
expect(patchedApi2?.content).toContain('try')
|
|
expect(patchedApi2?.content).toContain('catch')
|
|
|
|
// Sync files and config should be unchanged
|
|
expect(patchedUtils?.patch).toBe('')
|
|
expect(patchedConstants?.patch).toBe('')
|
|
expect(patchedConfig?.patch).toBe('')
|
|
|
|
// Check that async files have patches
|
|
expect(patchedApi1?.patch).not.toBe('')
|
|
expect(patchedApi2?.patch).not.toBe('')
|
|
|
|
expect(patchedApi1?.content).toMatchInlineSnapshot(`
|
|
"export async function fetchUser(id: number) {
|
|
try {
|
|
const response = await fetch('/api/user/' + id)
|
|
return response.json()
|
|
} catch (error) {
|
|
console.error('Error fetching user:', error)
|
|
throw error
|
|
}
|
|
}"
|
|
`)
|
|
expect(patchedApi1?.patch).toMatchInlineSnapshot(`
|
|
"◼︎=2-3|try {
|
|
const response = await fetch('/api/user/' + id)
|
|
return response.json()
|
|
} catch (error) {
|
|
console.error('Error fetching user:', error)
|
|
throw error
|
|
}"
|
|
`)
|
|
expect(patchedApi2?.content).toMatchInlineSnapshot(`
|
|
"export async function saveData(data: any) {
|
|
try {
|
|
const response = await fetch('/api/save', { method: 'POST', body: JSON.stringify(data) })
|
|
return response.json()
|
|
} catch (error) {
|
|
console.error('Error saving data:', error)
|
|
throw error
|
|
}
|
|
}"
|
|
`)
|
|
expect(patchedApi2?.patch).toMatchInlineSnapshot(`
|
|
"◼︎=2-3|try {
|
|
const response = await fetch('/api/save', { method: 'POST', body: JSON.stringify(data) })
|
|
return response.json()
|
|
} catch (error) {
|
|
console.error('Error saving data:', error)
|
|
throw error
|
|
}"
|
|
`)
|
|
})
|
|
|
|
it('patches a file with many lines (500 lines) updating ~50 lines spread throughout', async () => {
|
|
// Create a file with 500 lines where every 10th line has a TODO comment
|
|
const lines: string[] = []
|
|
for (let i = 0; i < 500; i++) {
|
|
if (i % 10 === 0) {
|
|
lines.push(` // TODO: implement feature ${i / 10}`)
|
|
} else {
|
|
lines.push(` const var${i} = ${i}`)
|
|
}
|
|
}
|
|
|
|
const largeFile: File = {
|
|
path: 'src/large.ts',
|
|
name: 'large.ts',
|
|
content: `function largeFunction() {\n${lines.join('\n')}\n}`,
|
|
}
|
|
|
|
const result = await zai.patch([largeFile], 'replace all TODO comments with FIXME comments').result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
const patched = result.output[0]
|
|
|
|
// All TODO should be replaced with FIXME
|
|
expect(patched.content).not.toContain('TODO')
|
|
expect(patched.content).toContain('FIXME')
|
|
|
|
// Should still have the function structure
|
|
expect(patched.content).toContain('function largeFunction()')
|
|
expect(patched.content).toContain('const var1 = 1')
|
|
expect(patched.content).toContain('const var499 = 499')
|
|
|
|
// Count FIXME occurrences (should be 50)
|
|
const fixmeCount = (patched.content.match(/FIXME/g) || []).length
|
|
expect(fixmeCount).toBe(50)
|
|
|
|
// Patch should have replacements
|
|
expect(patched.patch).toContain('FIXME')
|
|
expect(patched.patch).not.toBe('')
|
|
|
|
expect(patched.content).toMatchInlineSnapshot(`
|
|
"function largeFunction() {
|
|
// FIXME: implement feature 0
|
|
const var1 = 1
|
|
const var2 = 2
|
|
const var3 = 3
|
|
const var4 = 4
|
|
const var5 = 5
|
|
const var6 = 6
|
|
const var7 = 7
|
|
const var8 = 8
|
|
const var9 = 9
|
|
// FIXME: implement feature 1
|
|
const var11 = 11
|
|
const var12 = 12
|
|
const var13 = 13
|
|
const var14 = 14
|
|
const var15 = 15
|
|
const var16 = 16
|
|
const var17 = 17
|
|
const var18 = 18
|
|
const var19 = 19
|
|
// FIXME: implement feature 2
|
|
const var21 = 21
|
|
const var22 = 22
|
|
const var23 = 23
|
|
const var24 = 24
|
|
const var25 = 25
|
|
const var26 = 26
|
|
const var27 = 27
|
|
const var28 = 28
|
|
const var29 = 29
|
|
// FIXME: implement feature 3
|
|
const var31 = 31
|
|
const var32 = 32
|
|
const var33 = 33
|
|
const var34 = 34
|
|
const var35 = 35
|
|
const var36 = 36
|
|
const var37 = 37
|
|
const var38 = 38
|
|
const var39 = 39
|
|
// FIXME: implement feature 4
|
|
const var41 = 41
|
|
const var42 = 42
|
|
const var43 = 43
|
|
const var44 = 44
|
|
const var45 = 45
|
|
const var46 = 46
|
|
const var47 = 47
|
|
const var48 = 48
|
|
const var49 = 49
|
|
// FIXME: implement feature 5
|
|
const var51 = 51
|
|
const var52 = 52
|
|
const var53 = 53
|
|
const var54 = 54
|
|
const var55 = 55
|
|
const var56 = 56
|
|
const var57 = 57
|
|
const var58 = 58
|
|
const var59 = 59
|
|
// FIXME: implement feature 6
|
|
const var61 = 61
|
|
const var62 = 62
|
|
const var63 = 63
|
|
const var64 = 64
|
|
const var65 = 65
|
|
const var66 = 66
|
|
const var67 = 67
|
|
const var68 = 68
|
|
const var69 = 69
|
|
// FIXME: implement feature 7
|
|
const var71 = 71
|
|
const var72 = 72
|
|
const var73 = 73
|
|
const var74 = 74
|
|
const var75 = 75
|
|
const var76 = 76
|
|
const var77 = 77
|
|
const var78 = 78
|
|
const var79 = 79
|
|
// FIXME: implement feature 8
|
|
const var81 = 81
|
|
const var82 = 82
|
|
const var83 = 83
|
|
const var84 = 84
|
|
const var85 = 85
|
|
const var86 = 86
|
|
const var87 = 87
|
|
const var88 = 88
|
|
const var89 = 89
|
|
// FIXME: implement feature 9
|
|
const var91 = 91
|
|
const var92 = 92
|
|
const var93 = 93
|
|
const var94 = 94
|
|
const var95 = 95
|
|
const var96 = 96
|
|
const var97 = 97
|
|
const var98 = 98
|
|
const var99 = 99
|
|
// FIXME: implement feature 10
|
|
const var101 = 101
|
|
const var102 = 102
|
|
const var103 = 103
|
|
const var104 = 104
|
|
const var105 = 105
|
|
const var106 = 106
|
|
const var107 = 107
|
|
const var108 = 108
|
|
const var109 = 109
|
|
// FIXME: implement feature 11
|
|
const var111 = 111
|
|
const var112 = 112
|
|
const var113 = 113
|
|
const var114 = 114
|
|
const var115 = 115
|
|
const var116 = 116
|
|
const var117 = 117
|
|
const var118 = 118
|
|
const var119 = 119
|
|
// FIXME: implement feature 12
|
|
const var121 = 121
|
|
const var122 = 122
|
|
const var123 = 123
|
|
const var124 = 124
|
|
const var125 = 125
|
|
const var126 = 126
|
|
const var127 = 127
|
|
const var128 = 128
|
|
const var129 = 129
|
|
// FIXME: implement feature 13
|
|
const var131 = 131
|
|
const var132 = 132
|
|
const var133 = 133
|
|
const var134 = 134
|
|
const var135 = 135
|
|
const var136 = 136
|
|
const var137 = 137
|
|
const var138 = 138
|
|
const var139 = 139
|
|
// FIXME: implement feature 14
|
|
const var141 = 141
|
|
const var142 = 142
|
|
const var143 = 143
|
|
const var144 = 144
|
|
const var145 = 145
|
|
const var146 = 146
|
|
const var147 = 147
|
|
const var148 = 148
|
|
const var149 = 149
|
|
// FIXME: implement feature 15
|
|
const var151 = 151
|
|
const var152 = 152
|
|
const var153 = 153
|
|
const var154 = 154
|
|
const var155 = 155
|
|
const var156 = 156
|
|
const var157 = 157
|
|
const var158 = 158
|
|
const var159 = 159
|
|
// FIXME: implement feature 16
|
|
const var161 = 161
|
|
const var162 = 162
|
|
const var163 = 163
|
|
const var164 = 164
|
|
const var165 = 165
|
|
const var166 = 166
|
|
const var167 = 167
|
|
const var168 = 168
|
|
const var169 = 169
|
|
// FIXME: implement feature 17
|
|
const var171 = 171
|
|
const var172 = 172
|
|
const var173 = 173
|
|
const var174 = 174
|
|
const var175 = 175
|
|
const var176 = 176
|
|
const var177 = 177
|
|
const var178 = 178
|
|
const var179 = 179
|
|
// FIXME: implement feature 18
|
|
const var181 = 181
|
|
const var182 = 182
|
|
const var183 = 183
|
|
const var184 = 184
|
|
const var185 = 185
|
|
const var186 = 186
|
|
const var187 = 187
|
|
const var188 = 188
|
|
const var189 = 189
|
|
// FIXME: implement feature 19
|
|
const var191 = 191
|
|
const var192 = 192
|
|
const var193 = 193
|
|
const var194 = 194
|
|
const var195 = 195
|
|
const var196 = 196
|
|
const var197 = 197
|
|
const var198 = 198
|
|
const var199 = 199
|
|
// FIXME: implement feature 20
|
|
const var201 = 201
|
|
const var202 = 202
|
|
const var203 = 203
|
|
const var204 = 204
|
|
const var205 = 205
|
|
const var206 = 206
|
|
const var207 = 207
|
|
const var208 = 208
|
|
const var209 = 209
|
|
// FIXME: implement feature 21
|
|
const var211 = 211
|
|
const var212 = 212
|
|
const var213 = 213
|
|
const var214 = 214
|
|
const var215 = 215
|
|
const var216 = 216
|
|
const var217 = 217
|
|
const var218 = 218
|
|
const var219 = 219
|
|
// FIXME: implement feature 22
|
|
const var221 = 221
|
|
const var222 = 222
|
|
const var223 = 223
|
|
const var224 = 224
|
|
const var225 = 225
|
|
const var226 = 226
|
|
const var227 = 227
|
|
const var228 = 228
|
|
const var229 = 229
|
|
// FIXME: implement feature 23
|
|
const var231 = 231
|
|
const var232 = 232
|
|
const var233 = 233
|
|
const var234 = 234
|
|
const var235 = 235
|
|
const var236 = 236
|
|
const var237 = 237
|
|
const var238 = 238
|
|
const var239 = 239
|
|
// FIXME: implement feature 24
|
|
const var241 = 241
|
|
const var242 = 242
|
|
const var243 = 243
|
|
const var244 = 244
|
|
const var245 = 245
|
|
const var246 = 246
|
|
const var247 = 247
|
|
const var248 = 248
|
|
const var249 = 249
|
|
// FIXME: implement feature 25
|
|
const var251 = 251
|
|
const var252 = 252
|
|
const var253 = 253
|
|
const var254 = 254
|
|
const var255 = 255
|
|
const var256 = 256
|
|
const var257 = 257
|
|
const var258 = 258
|
|
const var259 = 259
|
|
// FIXME: implement feature 26
|
|
const var261 = 261
|
|
const var262 = 262
|
|
const var263 = 263
|
|
const var264 = 264
|
|
const var265 = 265
|
|
const var266 = 266
|
|
const var267 = 267
|
|
const var268 = 268
|
|
const var269 = 269
|
|
// FIXME: implement feature 27
|
|
const var271 = 271
|
|
const var272 = 272
|
|
const var273 = 273
|
|
const var274 = 274
|
|
const var275 = 275
|
|
const var276 = 276
|
|
const var277 = 277
|
|
const var278 = 278
|
|
const var279 = 279
|
|
// FIXME: implement feature 28
|
|
const var281 = 281
|
|
const var282 = 282
|
|
const var283 = 283
|
|
const var284 = 284
|
|
const var285 = 285
|
|
const var286 = 286
|
|
const var287 = 287
|
|
const var288 = 288
|
|
const var289 = 289
|
|
// FIXME: implement feature 29
|
|
const var291 = 291
|
|
const var292 = 292
|
|
const var293 = 293
|
|
const var294 = 294
|
|
const var295 = 295
|
|
const var296 = 296
|
|
const var297 = 297
|
|
const var298 = 298
|
|
const var299 = 299
|
|
// FIXME: implement feature 30
|
|
const var301 = 301
|
|
const var302 = 302
|
|
const var303 = 303
|
|
const var304 = 304
|
|
const var305 = 305
|
|
const var306 = 306
|
|
const var307 = 307
|
|
const var308 = 308
|
|
const var309 = 309
|
|
// FIXME: implement feature 31
|
|
const var311 = 311
|
|
const var312 = 312
|
|
const var313 = 313
|
|
const var314 = 314
|
|
const var315 = 315
|
|
const var316 = 316
|
|
const var317 = 317
|
|
const var318 = 318
|
|
const var319 = 319
|
|
// FIXME: implement feature 32
|
|
const var321 = 321
|
|
const var322 = 322
|
|
const var323 = 323
|
|
const var324 = 324
|
|
const var325 = 325
|
|
const var326 = 326
|
|
const var327 = 327
|
|
const var328 = 328
|
|
const var329 = 329
|
|
// FIXME: implement feature 33
|
|
const var331 = 331
|
|
const var332 = 332
|
|
const var333 = 333
|
|
const var334 = 334
|
|
const var335 = 335
|
|
const var336 = 336
|
|
const var337 = 337
|
|
const var338 = 338
|
|
const var339 = 339
|
|
// FIXME: implement feature 34
|
|
const var341 = 341
|
|
const var342 = 342
|
|
const var343 = 343
|
|
const var344 = 344
|
|
const var345 = 345
|
|
const var346 = 346
|
|
const var347 = 347
|
|
const var348 = 348
|
|
const var349 = 349
|
|
// FIXME: implement feature 35
|
|
const var351 = 351
|
|
const var352 = 352
|
|
const var353 = 353
|
|
const var354 = 354
|
|
const var355 = 355
|
|
const var356 = 356
|
|
const var357 = 357
|
|
const var358 = 358
|
|
const var359 = 359
|
|
// FIXME: implement feature 36
|
|
const var361 = 361
|
|
const var362 = 362
|
|
const var363 = 363
|
|
const var364 = 364
|
|
const var365 = 365
|
|
const var366 = 366
|
|
const var367 = 367
|
|
const var368 = 368
|
|
const var369 = 369
|
|
// FIXME: implement feature 37
|
|
const var371 = 371
|
|
const var372 = 372
|
|
const var373 = 373
|
|
const var374 = 374
|
|
const var375 = 375
|
|
const var376 = 376
|
|
const var377 = 377
|
|
const var378 = 378
|
|
const var379 = 379
|
|
// FIXME: implement feature 38
|
|
const var381 = 381
|
|
const var382 = 382
|
|
const var383 = 383
|
|
const var384 = 384
|
|
const var385 = 385
|
|
const var386 = 386
|
|
const var387 = 387
|
|
const var388 = 388
|
|
const var389 = 389
|
|
// FIXME: implement feature 39
|
|
const var391 = 391
|
|
const var392 = 392
|
|
const var393 = 393
|
|
const var394 = 394
|
|
const var395 = 395
|
|
const var396 = 396
|
|
const var397 = 397
|
|
const var398 = 398
|
|
const var399 = 399
|
|
// FIXME: implement feature 40
|
|
const var401 = 401
|
|
const var402 = 402
|
|
const var403 = 403
|
|
const var404 = 404
|
|
const var405 = 405
|
|
const var406 = 406
|
|
const var407 = 407
|
|
const var408 = 408
|
|
const var409 = 409
|
|
// FIXME: implement feature 41
|
|
const var411 = 411
|
|
const var412 = 412
|
|
const var413 = 413
|
|
const var414 = 414
|
|
const var415 = 415
|
|
const var416 = 416
|
|
const var417 = 417
|
|
const var418 = 418
|
|
const var419 = 419
|
|
// FIXME: implement feature 42
|
|
const var421 = 421
|
|
const var422 = 422
|
|
const var423 = 423
|
|
const var424 = 424
|
|
const var425 = 425
|
|
const var426 = 426
|
|
const var427 = 427
|
|
const var428 = 428
|
|
const var429 = 429
|
|
// FIXME: implement feature 43
|
|
const var431 = 431
|
|
const var432 = 432
|
|
const var433 = 433
|
|
const var434 = 434
|
|
const var435 = 435
|
|
const var436 = 436
|
|
const var437 = 437
|
|
const var438 = 438
|
|
const var439 = 439
|
|
// FIXME: implement feature 44
|
|
const var441 = 441
|
|
const var442 = 442
|
|
const var443 = 443
|
|
const var444 = 444
|
|
const var445 = 445
|
|
const var446 = 446
|
|
const var447 = 447
|
|
const var448 = 448
|
|
const var449 = 449
|
|
// FIXME: implement feature 45
|
|
const var451 = 451
|
|
const var452 = 452
|
|
const var453 = 453
|
|
const var454 = 454
|
|
const var455 = 455
|
|
const var456 = 456
|
|
const var457 = 457
|
|
const var458 = 458
|
|
const var459 = 459
|
|
// FIXME: implement feature 46
|
|
const var461 = 461
|
|
const var462 = 462
|
|
const var463 = 463
|
|
const var464 = 464
|
|
const var465 = 465
|
|
const var466 = 466
|
|
const var467 = 467
|
|
const var468 = 468
|
|
const var469 = 469
|
|
// FIXME: implement feature 47
|
|
const var471 = 471
|
|
const var472 = 472
|
|
const var473 = 473
|
|
const var474 = 474
|
|
const var475 = 475
|
|
const var476 = 476
|
|
const var477 = 477
|
|
const var478 = 478
|
|
const var479 = 479
|
|
// FIXME: implement feature 48
|
|
const var481 = 481
|
|
const var482 = 482
|
|
const var483 = 483
|
|
const var484 = 484
|
|
const var485 = 485
|
|
const var486 = 486
|
|
const var487 = 487
|
|
const var488 = 488
|
|
const var489 = 489
|
|
// FIXME: implement feature 49
|
|
const var491 = 491
|
|
const var492 = 492
|
|
const var493 = 493
|
|
const var494 = 494
|
|
const var495 = 495
|
|
const var496 = 496
|
|
const var497 = 497
|
|
const var498 = 498
|
|
const var499 = 499
|
|
}"
|
|
`)
|
|
expect(patched.patch).toMatchInlineSnapshot(`
|
|
"◼︎=2| // FIXME: implement feature 0
|
|
◼︎=12| // FIXME: implement feature 1
|
|
◼︎=22| // FIXME: implement feature 2
|
|
◼︎=32| // FIXME: implement feature 3
|
|
◼︎=42| // FIXME: implement feature 4
|
|
◼︎=52| // FIXME: implement feature 5
|
|
◼︎=62| // FIXME: implement feature 6
|
|
◼︎=72| // FIXME: implement feature 7
|
|
◼︎=82| // FIXME: implement feature 8
|
|
◼︎=92| // FIXME: implement feature 9
|
|
◼︎=102| // FIXME: implement feature 10
|
|
◼︎=112| // FIXME: implement feature 11
|
|
◼︎=122| // FIXME: implement feature 12
|
|
◼︎=132| // FIXME: implement feature 13
|
|
◼︎=142| // FIXME: implement feature 14
|
|
◼︎=152| // FIXME: implement feature 15
|
|
◼︎=162| // FIXME: implement feature 16
|
|
◼︎=172| // FIXME: implement feature 17
|
|
◼︎=182| // FIXME: implement feature 18
|
|
◼︎=192| // FIXME: implement feature 19
|
|
◼︎=202| // FIXME: implement feature 20
|
|
◼︎=212| // FIXME: implement feature 21
|
|
◼︎=222| // FIXME: implement feature 22
|
|
◼︎=232| // FIXME: implement feature 23
|
|
◼︎=242| // FIXME: implement feature 24
|
|
◼︎=252| // FIXME: implement feature 25
|
|
◼︎=262| // FIXME: implement feature 26
|
|
◼︎=272| // FIXME: implement feature 27
|
|
◼︎=282| // FIXME: implement feature 28
|
|
◼︎=292| // FIXME: implement feature 29
|
|
◼︎=302| // FIXME: implement feature 30
|
|
◼︎=312| // FIXME: implement feature 31
|
|
◼︎=322| // FIXME: implement feature 32
|
|
◼︎=332| // FIXME: implement feature 33
|
|
◼︎=342| // FIXME: implement feature 34
|
|
◼︎=352| // FIXME: implement feature 35
|
|
◼︎=362| // FIXME: implement feature 36
|
|
◼︎=372| // FIXME: implement feature 37
|
|
◼︎=382| // FIXME: implement feature 38
|
|
◼︎=392| // FIXME: implement feature 39
|
|
◼︎=402| // FIXME: implement feature 40
|
|
◼︎=412| // FIXME: implement feature 41
|
|
◼︎=422| // FIXME: implement feature 42
|
|
◼︎=432| // FIXME: implement feature 43
|
|
◼︎=442| // FIXME: implement feature 44
|
|
◼︎=452| // FIXME: implement feature 45
|
|
◼︎=462| // FIXME: implement feature 46
|
|
◼︎=472| // FIXME: implement feature 47
|
|
◼︎=482| // FIXME: implement feature 48
|
|
◼︎=492| // FIXME: implement feature 49"
|
|
`)
|
|
})
|
|
|
|
it('refactors a function in a large file (200 lines): renames it, changes parameters, updates all usages', async () => {
|
|
// Create a 200-line file with a function defined at the top and used throughout
|
|
const lines: string[] = []
|
|
lines.push('// Utility functions')
|
|
lines.push('function processItem(item: string): string {')
|
|
lines.push(' return item.toUpperCase()')
|
|
lines.push('}')
|
|
lines.push('')
|
|
lines.push('// Main code')
|
|
|
|
// Add 194 more lines with multiple usages of processItem
|
|
for (let i = 0; i < 194; i++) {
|
|
if (i % 20 === 0) {
|
|
lines.push(`const result${i} = processItem('value${i}')`)
|
|
} else if (i % 30 === 0) {
|
|
lines.push(`console.log(processItem('test${i}'))`)
|
|
} else {
|
|
lines.push(`const data${i} = 'some data ${i}'`)
|
|
}
|
|
}
|
|
|
|
const largeFile: File = {
|
|
path: 'src/processor.ts',
|
|
name: 'processor.ts',
|
|
content: lines.join('\n'),
|
|
}
|
|
|
|
const result = await zai
|
|
.patch(
|
|
[largeFile],
|
|
'rename processItem to transformText and add a second parameter "options: { uppercase: boolean }" with default value { uppercase: false }. Update all function calls to use the new signature. For string where number value in the 1st param is above 100, make it uppercase.'
|
|
)
|
|
.result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
const patched = result.output[0]
|
|
|
|
// Should not have old function name
|
|
expect(patched.content).not.toContain('processItem')
|
|
|
|
// Should have new function name
|
|
expect(patched.content).toContain('transformText')
|
|
|
|
// Should have the new parameter
|
|
expect(patched.content).toContain('options')
|
|
expect(patched.content).toContain('uppercase')
|
|
|
|
// All usages should be updated
|
|
const transformTextCount = (patched.content.match(/transformText/g) || []).length
|
|
expect(transformTextCount).toBeGreaterThan(1) // Function definition + usages
|
|
|
|
// Patch should not be empty
|
|
expect(patched.patch).not.toBe('')
|
|
|
|
expect(patched.content).toMatchInlineSnapshot(`
|
|
"// Utility functions
|
|
function transformText(item: string, options: { uppercase: boolean } = { uppercase: false }): string {
|
|
return options.uppercase ? item.toUpperCase() : item
|
|
}
|
|
|
|
// Main code
|
|
const result0 = transformText('value0', { uppercase: false })
|
|
const data1 = 'some data 1'
|
|
const data2 = 'some data 2'
|
|
const data3 = 'some data 3'
|
|
const data4 = 'some data 4'
|
|
const data5 = 'some data 5'
|
|
const data6 = 'some data 6'
|
|
const data7 = 'some data 7'
|
|
const data8 = 'some data 8'
|
|
const data9 = 'some data 9'
|
|
const data10 = 'some data 10'
|
|
const data11 = 'some data 11'
|
|
const data12 = 'some data 12'
|
|
const data13 = 'some data 13'
|
|
const data14 = 'some data 14'
|
|
const data15 = 'some data 15'
|
|
const data16 = 'some data 16'
|
|
const data17 = 'some data 17'
|
|
const data18 = 'some data 18'
|
|
const data19 = 'some data 19'
|
|
const result20 = transformText('value20', { uppercase: false })
|
|
const data21 = 'some data 21'
|
|
const data22 = 'some data 22'
|
|
const data23 = 'some data 23'
|
|
const data24 = 'some data 24'
|
|
const data25 = 'some data 25'
|
|
const data26 = 'some data 26'
|
|
const data27 = 'some data 27'
|
|
const data28 = 'some data 28'
|
|
const data29 = 'some data 29'
|
|
console.log(transformText('test30', { uppercase: false }))
|
|
const data31 = 'some data 31'
|
|
const data32 = 'some data 32'
|
|
const data33 = 'some data 33'
|
|
const data34 = 'some data 34'
|
|
const data35 = 'some data 35'
|
|
const data36 = 'some data 36'
|
|
const data37 = 'some data 37'
|
|
const data38 = 'some data 38'
|
|
const data39 = 'some data 39'
|
|
const result40 = transformText('value40', { uppercase: false })
|
|
const data41 = 'some data 41'
|
|
const data42 = 'some data 42'
|
|
const data43 = 'some data 43'
|
|
const data44 = 'some data 44'
|
|
const data45 = 'some data 45'
|
|
const data46 = 'some data 46'
|
|
const data47 = 'some data 47'
|
|
const data48 = 'some data 48'
|
|
const data49 = 'some data 49'
|
|
const data50 = 'some data 50'
|
|
const data51 = 'some data 51'
|
|
const data52 = 'some data 52'
|
|
const data53 = 'some data 53'
|
|
const data54 = 'some data 54'
|
|
const data55 = 'some data 55'
|
|
const data56 = 'some data 56'
|
|
const data57 = 'some data 57'
|
|
const data58 = 'some data 58'
|
|
const data59 = 'some data 59'
|
|
const result60 = transformText('value60', { uppercase: false })
|
|
const data61 = 'some data 61'
|
|
const data62 = 'some data 62'
|
|
const data63 = 'some data 63'
|
|
const data64 = 'some data 64'
|
|
const data65 = 'some data 65'
|
|
const data66 = 'some data 66'
|
|
const data67 = 'some data 67'
|
|
const data68 = 'some data 68'
|
|
const data69 = 'some data 69'
|
|
const data70 = 'some data 70'
|
|
const data71 = 'some data 71'
|
|
const data72 = 'some data 72'
|
|
const data73 = 'some data 73'
|
|
const data74 = 'some data 74'
|
|
const data75 = 'some data 75'
|
|
const data76 = 'some data 76'
|
|
const data77 = 'some data 77'
|
|
const data78 = 'some data 78'
|
|
const data79 = 'some data 79'
|
|
const result80 = transformText('value80', { uppercase: false })
|
|
const data81 = 'some data 81'
|
|
const data82 = 'some data 82'
|
|
const data83 = 'some data 83'
|
|
const data84 = 'some data 84'
|
|
const data85 = 'some data 85'
|
|
const data86 = 'some data 86'
|
|
const data87 = 'some data 87'
|
|
const data88 = 'some data 88'
|
|
const data89 = 'some data 89'
|
|
console.log(transformText('test90', { uppercase: false }))
|
|
const data91 = 'some data 91'
|
|
const data92 = 'some data 92'
|
|
const data93 = 'some data 93'
|
|
const data94 = 'some data 94'
|
|
const data95 = 'some data 95'
|
|
const data96 = 'some data 96'
|
|
const data97 = 'some data 97'
|
|
const data98 = 'some data 98'
|
|
const data99 = 'some data 99'
|
|
const result100 = transformText('value100', { uppercase: false })
|
|
const data101 = 'some data 101'
|
|
const data102 = 'some data 102'
|
|
const data103 = 'some data 103'
|
|
const data104 = 'some data 104'
|
|
const data105 = 'some data 105'
|
|
const data106 = 'some data 106'
|
|
const data107 = 'some data 107'
|
|
const data108 = 'some data 108'
|
|
const data109 = 'some data 109'
|
|
const data110 = 'some data 110'
|
|
const data111 = 'some data 111'
|
|
const data112 = 'some data 112'
|
|
const data113 = 'some data 113'
|
|
const data114 = 'some data 114'
|
|
const data115 = 'some data 115'
|
|
const data116 = 'some data 116'
|
|
const data117 = 'some data 117'
|
|
const data118 = 'some data 118'
|
|
const data119 = 'some data 119'
|
|
const result120 = transformText('value120', { uppercase: true })
|
|
const data121 = 'some data 121'
|
|
const data122 = 'some data 122'
|
|
const data123 = 'some data 123'
|
|
const data124 = 'some data 124'
|
|
const data125 = 'some data 125'
|
|
const data126 = 'some data 126'
|
|
const data127 = 'some data 127'
|
|
const data128 = 'some data 128'
|
|
const data129 = 'some data 129'
|
|
const data130 = 'some data 130'
|
|
const data131 = 'some data 131'
|
|
const data132 = 'some data 132'
|
|
const data133 = 'some data 133'
|
|
const data134 = 'some data 134'
|
|
const data135 = 'some data 135'
|
|
const data136 = 'some data 136'
|
|
const data137 = 'some data 137'
|
|
const data138 = 'some data 138'
|
|
const data139 = 'some data 139'
|
|
const result140 = transformText('value140', { uppercase: true })
|
|
const data141 = 'some data 141'
|
|
const data142 = 'some data 142'
|
|
const data143 = 'some data 143'
|
|
const data144 = 'some data 144'
|
|
const data145 = 'some data 145'
|
|
const data146 = 'some data 146'
|
|
const data147 = 'some data 147'
|
|
const data148 = 'some data 148'
|
|
const data149 = 'some data 149'
|
|
console.log(transformText('test150', { uppercase: true }))
|
|
const data151 = 'some data 151'
|
|
const data152 = 'some data 152'
|
|
const data153 = 'some data 153'
|
|
const data154 = 'some data 154'
|
|
const data155 = 'some data 155'
|
|
const data156 = 'some data 156'
|
|
const data157 = 'some data 157'
|
|
const data158 = 'some data 158'
|
|
const data159 = 'some data 159'
|
|
const result160 = transformText('value160', { uppercase: true })
|
|
const data161 = 'some data 161'
|
|
const data162 = 'some data 162'
|
|
const data163 = 'some data 163'
|
|
const data164 = 'some data 164'
|
|
const data165 = 'some data 165'
|
|
const data166 = 'some data 166'
|
|
const data167 = 'some data 167'
|
|
const data168 = 'some data 168'
|
|
const data169 = 'some data 169'
|
|
const data170 = 'some data 170'
|
|
const data171 = 'some data 171'
|
|
const data172 = 'some data 172'
|
|
const data173 = 'some data 173'
|
|
const data174 = 'some data 174'
|
|
const data175 = 'some data 175'
|
|
const data176 = 'some data 176'
|
|
const data177 = 'some data 177'
|
|
const data178 = 'some data 178'
|
|
const data179 = 'some data 179'
|
|
const result180 = transformText('value180', { uppercase: true })
|
|
const data181 = 'some data 181'
|
|
const data182 = 'some data 182'
|
|
const data183 = 'some data 183'
|
|
const data184 = 'some data 184'
|
|
const data185 = 'some data 185'
|
|
const data186 = 'some data 186'
|
|
const data187 = 'some data 187'
|
|
const data188 = 'some data 188'
|
|
const data189 = 'some data 189'
|
|
const data190 = 'some data 190'
|
|
const data191 = 'some data 191'
|
|
const data192 = 'some data 192'
|
|
const data193 = 'some data 193'"
|
|
`)
|
|
expect(patched.patch).toMatchInlineSnapshot(`
|
|
"◼︎=2-4|function transformText(item: string, options: { uppercase: boolean } = { uppercase: false }): string {
|
|
return options.uppercase ? item.toUpperCase() : item
|
|
}
|
|
◼︎=7|const result0 = transformText('value0', { uppercase: false })
|
|
◼︎=27|const result20 = transformText('value20', { uppercase: false })
|
|
◼︎=37|console.log(transformText('test30', { uppercase: false }))
|
|
◼︎=47|const result40 = transformText('value40', { uppercase: false })
|
|
◼︎=67|const result60 = transformText('value60', { uppercase: false })
|
|
◼︎=87|const result80 = transformText('value80', { uppercase: false })
|
|
◼︎=97|console.log(transformText('test90', { uppercase: false }))
|
|
◼︎=107|const result100 = transformText('value100', { uppercase: false })
|
|
◼︎=127|const result120 = transformText('value120', { uppercase: true })
|
|
◼︎=147|const result140 = transformText('value140', { uppercase: true })
|
|
◼︎=157|console.log(transformText('test150', { uppercase: true }))
|
|
◼︎=167|const result160 = transformText('value160', { uppercase: true })
|
|
◼︎=187|const result180 = transformText('value180', { uppercase: true })"
|
|
`)
|
|
})
|
|
|
|
it('handles large file with chunking enabled (single file > maxTokensPerChunk)', async () => {
|
|
// Create a very large file (2000 lines)
|
|
const lines: string[] = []
|
|
for (let i = 0; i < 2000; i++) {
|
|
if (i % 100 === 0) {
|
|
lines.push(` // TODO: section ${i / 100}`)
|
|
} else {
|
|
lines.push(` const variable${i} = ${i}`)
|
|
}
|
|
}
|
|
|
|
const veryLargeFile: File = {
|
|
path: 'src/giant.ts',
|
|
name: 'giant.ts',
|
|
content: `function giant() {\n${lines.join('\n')}\n}`,
|
|
}
|
|
|
|
const result = await zai
|
|
.patch([veryLargeFile], 'replace all TODO comments with DONE comments', {
|
|
maxTokensPerChunk: 5000, // Force chunking
|
|
})
|
|
.result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
const patched = result.output[0]
|
|
|
|
// Verify chunking occurred - multiple LLM calls were made
|
|
expect(result.usage.requests.requests).toBeGreaterThan(1)
|
|
console.log(`Chunking test: ${result.usage.requests.requests} LLM calls made`)
|
|
|
|
// All TODO should be replaced with DONE
|
|
expect(patched.content).not.toContain('TODO')
|
|
expect(patched.content).toContain('DONE')
|
|
|
|
// Should still have the function structure
|
|
expect(patched.content).toContain('function giant()')
|
|
|
|
// Count DONE occurrences
|
|
const doneCount = (patched.content.match(/DONE/g) || []).length
|
|
expect(doneCount).toBe(20)
|
|
|
|
expect(patched.patch).toContain('DONE')
|
|
expect(patched.patch).not.toBe('')
|
|
})
|
|
|
|
it('handles multiple files with chunking (total files > maxTokensPerChunk)', async () => {
|
|
// Create 5 moderately sized files
|
|
const files: File[] = []
|
|
for (let fileIdx = 0; fileIdx < 5; fileIdx++) {
|
|
const lines: string[] = []
|
|
for (let i = 0; i < 300; i++) {
|
|
if (i % 50 === 0) {
|
|
lines.push(` // TODO: feature ${fileIdx}-${i / 50}`)
|
|
} else {
|
|
lines.push(` const var${i} = ${i}`)
|
|
}
|
|
}
|
|
|
|
files.push({
|
|
path: `src/file${fileIdx}.ts`,
|
|
name: `file${fileIdx}.ts`,
|
|
content: `function file${fileIdx}() {\n${lines.join('\n')}\n}`,
|
|
})
|
|
}
|
|
|
|
const result = await zai
|
|
.patch(files, 'replace all TODO comments with COMPLETED comments', {
|
|
maxTokensPerChunk: 8000, // Force batching
|
|
})
|
|
.result()
|
|
|
|
expect(result.output).toHaveLength(5)
|
|
|
|
// Verify batching occurred - multiple LLM calls were made
|
|
expect(result.usage.requests.requests).toBeGreaterThan(1)
|
|
console.log(`Batching test: ${result.usage.requests.requests} LLM calls made`)
|
|
|
|
// Check that all files were processed
|
|
for (let i = 0; i < 5; i++) {
|
|
const patched = result.output.find((f) => f.name === `file${i}.ts`)
|
|
expect(patched).toBeDefined()
|
|
expect(patched?.content).not.toContain('TODO')
|
|
expect(patched?.content).toContain('COMPLETED')
|
|
|
|
// Each file should have 6 COMPLETED comments
|
|
const completedCount = (patched?.content.match(/COMPLETED/g) || []).length
|
|
expect(completedCount).toBe(6)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe.sequential('zai.learn.patch', { timeout: 60_000 }, () => {
|
|
const client = getClient()
|
|
let tableName = 'ZaiTestPatchInternalTable'
|
|
let taskId = 'patch'
|
|
let zai = getZai()
|
|
|
|
beforeEach(async () => {
|
|
zai = getZai().with({
|
|
activeLearning: {
|
|
enable: true,
|
|
taskId,
|
|
tableName,
|
|
},
|
|
})
|
|
})
|
|
|
|
afterEach(async () => {
|
|
try {
|
|
await client.deleteTableRows({ table: tableName, deleteAllRows: true })
|
|
} catch (err) {}
|
|
})
|
|
|
|
afterAll(async () => {
|
|
try {
|
|
await client.deleteTable({ table: tableName })
|
|
} catch (err) {}
|
|
})
|
|
|
|
it('learns patch patterns from examples', async () => {
|
|
const adapter = new TableAdapter({
|
|
client,
|
|
tableName,
|
|
})
|
|
|
|
const testFile: File = {
|
|
path: 'component.tsx',
|
|
name: 'component.tsx',
|
|
content: 'export const Component = () => <div>Test</div>',
|
|
}
|
|
|
|
// First call - learns the pattern
|
|
const first = await zai.learn(taskId).patch([testFile], 'add TypeScript FC type').result()
|
|
|
|
let rows = await client.findTableRows({ table: tableName })
|
|
expect(rows.rows.length).toBeGreaterThanOrEqual(1)
|
|
|
|
// Save an approved example with a specific pattern
|
|
await adapter.saveExample({
|
|
key: 'example1',
|
|
taskId: `zai/${taskId}`,
|
|
taskType: 'zai.patch',
|
|
instructions: 'add TypeScript FC type',
|
|
input: [
|
|
{
|
|
path: 'test.tsx',
|
|
name: 'test.tsx',
|
|
content: 'export const MyComponent = () => <div>Hello</div>',
|
|
},
|
|
],
|
|
output: [
|
|
{
|
|
path: 'test.tsx',
|
|
name: 'test.tsx',
|
|
content: 'import { FC } from "react"\n\nexport const MyComponent: FC = () => <div>Hello</div>',
|
|
},
|
|
],
|
|
metadata,
|
|
status: 'approved',
|
|
})
|
|
|
|
// Second call - should use the learned pattern
|
|
const second = await zai.learn(taskId).patch([testFile], 'add TypeScript FC type').result()
|
|
|
|
rows = await client.findTableRows({ table: tableName })
|
|
expect(rows.rows.length).toBe(2)
|
|
|
|
expect(second.output[0].content).toContain('FC')
|
|
expect(second.output[0].content).toContain('react')
|
|
|
|
expect(second.output[0].patch).toMatchInlineSnapshot(`
|
|
"◼︎<1|import React from 'react';
|
|
◼︎=1|export const Component: React.FC = () => <div>Test</div>"
|
|
`)
|
|
})
|
|
})
|
|
|
|
describe('zai.patch — JSON file operations', { timeout: 60_000 }, () => {
|
|
const zai = getZai()
|
|
|
|
it('changes a value inside a simple JSON object', async () => {
|
|
const file: File = {
|
|
path: 'config.json',
|
|
name: 'config.json',
|
|
content: JSON.stringify({ name: 'my-app', version: '1.0.0', debug: false }, null, 2),
|
|
}
|
|
|
|
const { output } = await zai.patch([file], 'change the version to "2.0.0"').result()
|
|
|
|
expect(output).toHaveLength(1)
|
|
expect(output[0].name).toBe('config.json')
|
|
expect(output[0].path).toBe('config.json')
|
|
expect(output[0].content).toMatch(JSON.stringify({ name: 'my-app', version: '2.0.0', debug: false }, null, 2))
|
|
})
|
|
|
|
it('changes multiple properties inside a simple JSON object', async () => {
|
|
const file: File = {
|
|
path: 'config.json',
|
|
name: 'config.json',
|
|
content: JSON.stringify({ name: 'my-app', version: '1.0.0', debug: false, port: 3000 }, null, 2),
|
|
}
|
|
|
|
const result = await zai
|
|
.patch([file], 'make the name "super-app", debug = true, change port should be 8080')
|
|
.result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
const parsed = JSON.parse(result.output[0].content)
|
|
expect(parsed.name).toBe('super-app')
|
|
expect(parsed.debug).toBe(true)
|
|
expect(parsed.port).toBe(8080)
|
|
expect(parsed.version).toBe('1.0.0')
|
|
})
|
|
|
|
it('changes multiple properties inside a complex JSON object', async () => {
|
|
const file: File = {
|
|
path: 'config.json',
|
|
name: 'config.json',
|
|
content: JSON.stringify(
|
|
{
|
|
app: { name: 'my-app', version: '1.0.0' },
|
|
server: { host: 'localhost', port: 3000, ssl: { enabled: false, cert: '' } },
|
|
logging: { level: 'info', format: 'text' },
|
|
},
|
|
null,
|
|
2
|
|
),
|
|
}
|
|
|
|
const result = await zai
|
|
.patch(
|
|
[file],
|
|
'change app.name to "production-app", server.port to 443, server.ssl.enabled to true, and logging.level to "warn"'
|
|
)
|
|
.result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
const parsed = JSON.parse(result.output[0].content)
|
|
expect(parsed.app.name).toBe('production-app')
|
|
expect(parsed.app.version).toBe('1.0.0')
|
|
expect(parsed.server.port).toBe(443)
|
|
expect(parsed.server.ssl.enabled).toBe(true)
|
|
expect(parsed.server.host).toBe('localhost')
|
|
expect(parsed.logging.level).toBe('warn')
|
|
expect(parsed.logging.format).toBe('text')
|
|
})
|
|
|
|
it('adds additional properties in a simple JSON', async () => {
|
|
const file: File = {
|
|
path: 'config.json',
|
|
name: 'config.json',
|
|
content: JSON.stringify({ name: 'my-app', version: '1.0.0' }, null, 2),
|
|
}
|
|
|
|
const result = await zai
|
|
.patch([file], 'add "description": "A sample application" and "license": "MIT" to the JSON')
|
|
.result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
const parsed = JSON.parse(result.output[0].content)
|
|
expect(parsed.name).toBe('my-app')
|
|
expect(parsed.version).toBe('1.0.0')
|
|
expect(parsed.description).toBe('A sample application')
|
|
expect(parsed.license).toBe('MIT')
|
|
})
|
|
|
|
it('adds additional properties in a complex JSON', async () => {
|
|
const file: File = {
|
|
path: 'config.json',
|
|
name: 'config.json',
|
|
content: JSON.stringify(
|
|
{
|
|
database: { host: 'localhost', port: 5432 },
|
|
cache: { enabled: true },
|
|
},
|
|
null,
|
|
2
|
|
),
|
|
}
|
|
|
|
const result = await zai
|
|
.patch(
|
|
[file],
|
|
'add "name": "mydb" and "ssl": true inside the database object, and add "ttl": 3600 inside the cache object'
|
|
)
|
|
.result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
const parsed = JSON.parse(result.output[0].content)
|
|
expect(parsed.database.host).toBe('localhost')
|
|
expect(parsed.database.port).toBe(5432)
|
|
expect(parsed.database.name).toBe('mydb')
|
|
expect(parsed.database.ssl).toBe(true)
|
|
expect(parsed.cache.enabled).toBe(true)
|
|
expect(parsed.cache.ttl).toBe(3600)
|
|
})
|
|
|
|
it('removes a property from a simple JSON', async () => {
|
|
const file: File = {
|
|
path: 'config.json',
|
|
name: 'config.json',
|
|
content: JSON.stringify({ name: 'my-app', version: '1.0.0', deprecated: true, debug: false }, null, 2),
|
|
}
|
|
|
|
const result = await zai.patch([file], 'remove the "deprecated" property').result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
const parsed = JSON.parse(result.output[0].content)
|
|
expect(parsed.name).toBe('my-app')
|
|
expect(parsed.version).toBe('1.0.0')
|
|
expect(parsed.debug).toBe(false)
|
|
expect(parsed).not.toHaveProperty('deprecated')
|
|
})
|
|
|
|
it('removes multiple properties from a simple JSON', async () => {
|
|
const file: File = {
|
|
path: 'config.json',
|
|
name: 'config.json',
|
|
content: JSON.stringify(
|
|
{ name: 'my-app', version: '1.0.0', deprecated: true, temporary: 'value', debug: false, legacy: true },
|
|
null,
|
|
2
|
|
),
|
|
}
|
|
|
|
const result = await zai.patch([file], 'remove the "deprecated", "temporary", and "legacy" properties').result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
const parsed = JSON.parse(result.output[0].content)
|
|
expect(parsed.name).toBe('my-app')
|
|
expect(parsed.version).toBe('1.0.0')
|
|
expect(parsed.debug).toBe(false)
|
|
expect(parsed).not.toHaveProperty('deprecated')
|
|
expect(parsed).not.toHaveProperty('temporary')
|
|
expect(parsed).not.toHaveProperty('legacy')
|
|
})
|
|
|
|
it('removes items inside a JSON array inside a JSON object', async () => {
|
|
const file: File = {
|
|
path: 'config.json',
|
|
name: 'config.json',
|
|
content: JSON.stringify(
|
|
{
|
|
name: 'my-app',
|
|
contributors: [
|
|
{ name: 'Alice', role: 'lead' },
|
|
{ name: 'Bob', role: 'dev' },
|
|
{ name: 'Charlie', role: 'intern' },
|
|
{ name: 'Diana', role: 'dev' },
|
|
],
|
|
},
|
|
null,
|
|
2
|
|
),
|
|
}
|
|
|
|
const result = await zai.patch([file], 'remove Bob and Charlie from the contributors array').result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
const parsed = JSON.parse(result.output[0].content)
|
|
expect(parsed.name).toBe('my-app')
|
|
expect(parsed.contributors).toHaveLength(2)
|
|
expect(parsed.contributors.map((c: any) => c.name)).toEqual(['Alice', 'Diana'])
|
|
})
|
|
|
|
it('adds, removes, and changes properties inside a simple JSON', async () => {
|
|
const file: File = {
|
|
path: 'package.json',
|
|
name: 'package.json',
|
|
content: JSON.stringify({ name: 'old-name', version: '0.1.0', private: true, deprecated: true }, null, 2),
|
|
}
|
|
|
|
const result = await zai
|
|
.patch(
|
|
[file],
|
|
'change "name" to "new-name", change "version" to "1.0.0", remove the "deprecated" property, and add "license": "MIT"'
|
|
)
|
|
.result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
const parsed = JSON.parse(result.output[0].content)
|
|
expect(parsed.name).toBe('new-name')
|
|
expect(parsed.version).toBe('1.0.0')
|
|
expect(parsed.private).toBe(true)
|
|
expect(parsed.license).toBe('MIT')
|
|
expect(parsed).not.toHaveProperty('deprecated')
|
|
})
|
|
|
|
it('adds, removes, and changes properties inside a complex JSON', async () => {
|
|
const file: File = {
|
|
path: 'config.json',
|
|
name: 'config.json',
|
|
content: JSON.stringify(
|
|
{
|
|
app: { name: 'old-app', version: '0.1.0', experimental: true },
|
|
database: { host: 'localhost', port: 5432, pool: { min: 2, max: 10, idleTimeout: 30000 } },
|
|
features: { darkMode: false, betaFeatures: true, analytics: { enabled: false, provider: 'none' } },
|
|
},
|
|
null,
|
|
2
|
|
),
|
|
}
|
|
|
|
const result = await zai
|
|
.patch(
|
|
[file],
|
|
'change app.name to "new-app" and app.version to "1.0.0", remove app.experimental, change database.pool.max to 20, remove features.betaFeatures, change features.analytics.enabled to true and features.analytics.provider to "mixpanel", and add features.darkMode as true and database.pool.timeout as 5000'
|
|
)
|
|
.result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
const parsed = JSON.parse(result.output[0].content)
|
|
|
|
// Changed
|
|
expect(parsed.app.name).toBe('new-app')
|
|
expect(parsed.app.version).toBe('1.0.0')
|
|
expect(parsed.database.pool.max).toBe(20)
|
|
expect(parsed.features.analytics.enabled).toBe(true)
|
|
expect(parsed.features.analytics.provider).toBe('mixpanel')
|
|
expect(parsed.features.darkMode).toBe(true)
|
|
|
|
// Removed
|
|
expect(parsed.app).not.toHaveProperty('experimental')
|
|
expect(parsed.features).not.toHaveProperty('betaFeatures')
|
|
|
|
// Added
|
|
expect(parsed.database.pool.timeout).toBe(5000)
|
|
|
|
// Unchanged
|
|
expect(parsed.database.host).toBe('localhost')
|
|
expect(parsed.database.port).toBe(5432)
|
|
expect(parsed.database.pool.min).toBe(2)
|
|
expect(parsed.database.pool.idleTimeout).toBe(30000)
|
|
})
|
|
|
|
it('restructures a realistic package.json with many simultaneous changes', async () => {
|
|
const file: File = {
|
|
path: 'package.json',
|
|
name: 'package.json',
|
|
content: JSON.stringify(
|
|
{
|
|
name: '@acme/api-server',
|
|
version: '2.4.1',
|
|
description: 'Acme API Server',
|
|
main: 'dist/index.js',
|
|
scripts: {
|
|
build: 'tsc',
|
|
dev: 'ts-node src/index.ts',
|
|
test: 'jest',
|
|
lint: 'eslint src/',
|
|
'db:migrate': 'knex migrate:latest',
|
|
'db:seed': 'knex seed:run',
|
|
},
|
|
dependencies: {
|
|
express: '^4.18.2',
|
|
knex: '^3.1.0',
|
|
pg: '^8.11.3',
|
|
zod: '^3.22.4',
|
|
dotenv: '^16.3.1',
|
|
cors: '^2.8.5',
|
|
helmet: '^7.1.0',
|
|
jsonwebtoken: '^9.0.2',
|
|
bcrypt: '^5.1.1',
|
|
},
|
|
devDependencies: {
|
|
typescript: '^5.3.3',
|
|
'@types/node': '^20.10.0',
|
|
'@types/express': '^4.17.21',
|
|
jest: '^29.7.0',
|
|
'ts-jest': '^29.1.1',
|
|
eslint: '^8.55.0',
|
|
'@types/cors': '^2.8.17',
|
|
'@types/jsonwebtoken': '^9.0.5',
|
|
'@types/bcrypt': '^5.0.2',
|
|
},
|
|
engines: { node: '>=18.0.0' },
|
|
repository: {
|
|
type: 'git',
|
|
url: 'https://github.com/acme/api-server.git',
|
|
},
|
|
author: 'Acme Corp',
|
|
license: 'UNLICENSED',
|
|
private: true,
|
|
},
|
|
null,
|
|
2
|
|
),
|
|
}
|
|
|
|
const result = await zai
|
|
.patch(
|
|
[file],
|
|
[
|
|
'Migrate from Express+Jest+ESLint to Hono+Vitest+Biome:',
|
|
'- change scripts.build to "bun build src/index.ts --outdir dist"',
|
|
'- change scripts.dev to "bun --hot src/index.ts"',
|
|
'- change scripts.test to "vitest"',
|
|
'- change scripts.lint to "biome check src/"',
|
|
'- remove express, cors, helmet, dotenv from dependencies',
|
|
'- add hono "^4.0.0" to dependencies',
|
|
'- remove all @types/* packages, jest, ts-jest, eslint from devDependencies',
|
|
'- add vitest "^1.2.0" and @biomejs/biome "^1.5.0" to devDependencies',
|
|
'- bump the version to "3.0.0"',
|
|
'- change engines.node to ">=20.0.0"',
|
|
'- change license to "MIT"',
|
|
'- remove the private field',
|
|
'- add a "type": "module" field',
|
|
].join('\n')
|
|
)
|
|
.result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
const parsed = JSON.parse(result.output[0].content)
|
|
|
|
// Version & metadata
|
|
expect(parsed.version).toBe('3.0.0')
|
|
expect(parsed.license).toBe('MIT')
|
|
expect(parsed).not.toHaveProperty('private')
|
|
expect(parsed.type).toBe('module')
|
|
expect(parsed.engines.node).toBe('>=20.0.0')
|
|
|
|
// Scripts
|
|
expect(parsed.scripts.test).toBe('vitest')
|
|
expect(parsed.scripts.lint).toBe('biome check src/')
|
|
expect(parsed.scripts.dev).toContain('bun')
|
|
expect(parsed.scripts.build).toContain('bun')
|
|
|
|
// Dependencies — removed
|
|
expect(parsed.dependencies).not.toHaveProperty('express')
|
|
expect(parsed.dependencies).not.toHaveProperty('cors')
|
|
expect(parsed.dependencies).not.toHaveProperty('helmet')
|
|
expect(parsed.dependencies).not.toHaveProperty('dotenv')
|
|
|
|
// Dependencies — added
|
|
expect(parsed.dependencies.hono).toBeDefined()
|
|
|
|
// Dependencies — kept
|
|
expect(parsed.dependencies.knex).toBeDefined()
|
|
expect(parsed.dependencies.pg).toBeDefined()
|
|
expect(parsed.dependencies.zod).toBeDefined()
|
|
expect(parsed.dependencies.jsonwebtoken).toBeDefined()
|
|
expect(parsed.dependencies.bcrypt).toBeDefined()
|
|
|
|
// DevDependencies — removed
|
|
expect(parsed.devDependencies).not.toHaveProperty('jest')
|
|
expect(parsed.devDependencies).not.toHaveProperty('ts-jest')
|
|
expect(parsed.devDependencies).not.toHaveProperty('eslint')
|
|
expect(parsed.devDependencies).not.toHaveProperty('@types/node')
|
|
expect(parsed.devDependencies).not.toHaveProperty('@types/express')
|
|
expect(parsed.devDependencies).not.toHaveProperty('@types/cors')
|
|
expect(parsed.devDependencies).not.toHaveProperty('@types/jsonwebtoken')
|
|
expect(parsed.devDependencies).not.toHaveProperty('@types/bcrypt')
|
|
|
|
// DevDependencies — added
|
|
expect(parsed.devDependencies.vitest).toBeDefined()
|
|
expect(parsed.devDependencies['@biomejs/biome']).toBeDefined()
|
|
|
|
// DevDependencies — kept
|
|
expect(parsed.devDependencies.typescript).toBeDefined()
|
|
|
|
// Unchanged
|
|
expect(parsed.name).toBe('@acme/api-server')
|
|
expect(parsed.description).toBe('Acme API Server')
|
|
expect(parsed.scripts['db:migrate']).toBe('knex migrate:latest')
|
|
expect(parsed.scripts['db:seed']).toBe('knex seed:run')
|
|
expect(parsed.repository.url).toBe('https://github.com/acme/api-server.git')
|
|
expect(parsed.author).toBe('Acme Corp')
|
|
})
|
|
|
|
it('restructures deep nested objects and arrays in a CI/CD pipeline config', async () => {
|
|
const file: File = {
|
|
path: 'pipeline.json',
|
|
name: 'pipeline.json',
|
|
content: JSON.stringify(
|
|
{
|
|
pipeline: {
|
|
name: 'deploy-prod',
|
|
version: 1,
|
|
triggers: [
|
|
{ type: 'push', branches: ['main', 'release/*'] },
|
|
{ type: 'schedule', cron: '0 2 * * 1' },
|
|
{ type: 'manual', approvers: ['alice', 'bob', 'charlie'] },
|
|
],
|
|
stages: [
|
|
{
|
|
name: 'build',
|
|
timeout: 600,
|
|
steps: [
|
|
{ run: 'npm ci', cache: true },
|
|
{ run: 'npm run build', artifacts: ['dist/**'] },
|
|
{ run: 'npm test', retry: 2 },
|
|
],
|
|
},
|
|
{
|
|
name: 'security',
|
|
timeout: 300,
|
|
steps: [
|
|
{ run: 'npm audit', allowFailure: true },
|
|
{ run: 'snyk test', env: { SNYK_TOKEN: '$SNYK_TOKEN' } },
|
|
],
|
|
},
|
|
{
|
|
name: 'deploy',
|
|
timeout: 900,
|
|
dependsOn: ['build', 'security'],
|
|
steps: [
|
|
{ run: 'docker build -t app:latest .' },
|
|
{ run: 'docker push app:latest' },
|
|
{ run: 'kubectl apply -f k8s/', retry: 3 },
|
|
],
|
|
},
|
|
],
|
|
notifications: {
|
|
onSuccess: [{ type: 'slack', channel: '#deploys' }],
|
|
onFailure: [
|
|
{ type: 'slack', channel: '#deploys' },
|
|
{ type: 'email', recipients: ['oncall@acme.com'] },
|
|
{ type: 'pagerduty', severity: 'high' },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2
|
|
),
|
|
}
|
|
|
|
const result = await zai
|
|
.patch(
|
|
[file],
|
|
[
|
|
'Make the following changes to the pipeline config:',
|
|
'- bump pipeline.version to 2',
|
|
'- remove the schedule trigger from the triggers array',
|
|
'- remove "charlie" from the manual trigger approvers and add "diana"',
|
|
'- remove the entire security stage from stages',
|
|
'- in the deploy stage: remove "security" from dependsOn (keep "build"), and change retry on the kubectl step to 5',
|
|
'- in notifications.onFailure: remove the pagerduty entry and add "platform@acme.com" to the email recipients',
|
|
].join('\n')
|
|
)
|
|
.result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
const parsed = JSON.parse(result.output[0].content)
|
|
const p = parsed.pipeline
|
|
|
|
// Version
|
|
expect(p.version).toBe(2)
|
|
|
|
// Triggers — schedule removed
|
|
expect(p.triggers.find((t: any) => t.type === 'schedule')).toBeUndefined()
|
|
// Triggers — push unchanged
|
|
const pushTrigger = p.triggers.find((t: any) => t.type === 'push')
|
|
expect(pushTrigger.branches).toContain('main')
|
|
expect(pushTrigger.branches).toContain('release/*')
|
|
// Triggers — manual approvers updated
|
|
const manualTrigger = p.triggers.find((t: any) => t.type === 'manual')
|
|
expect(manualTrigger.approvers).toContain('alice')
|
|
expect(manualTrigger.approvers).toContain('bob')
|
|
expect(manualTrigger.approvers).not.toContain('charlie')
|
|
expect(manualTrigger.approvers).toContain('diana')
|
|
|
|
// Stages — security removed
|
|
expect(p.stages.find((s: any) => s.name === 'security')).toBeUndefined()
|
|
|
|
// Stages — build unchanged
|
|
const build = p.stages.find((s: any) => s.name === 'build')
|
|
expect(build.timeout).toBe(600)
|
|
expect(build.steps).toHaveLength(3)
|
|
|
|
// Stages — deploy
|
|
const deploy = p.stages.find((s: any) => s.name === 'deploy')
|
|
expect(deploy.dependsOn).toContain('build')
|
|
expect(deploy.dependsOn).not.toContain('security')
|
|
const kubectlStep = deploy.steps.find((s: any) => s.run.includes('kubectl'))
|
|
expect(kubectlStep.retry).toBe(5)
|
|
|
|
// Notifications — pagerduty removed
|
|
expect(p.notifications.onFailure.find((n: any) => n.type === 'pagerduty')).toBeUndefined()
|
|
// Notifications — email recipients updated
|
|
const emailNotif = p.notifications.onFailure.find((n: any) => n.type === 'email')
|
|
expect(emailNotif.recipients).toContain('oncall@acme.com')
|
|
expect(emailNotif.recipients).toContain('platform@acme.com')
|
|
|
|
// Unchanged
|
|
expect(p.name).toBe('deploy-prod')
|
|
expect(p.notifications.onSuccess).toHaveLength(1)
|
|
expect(p.notifications.onSuccess[0].channel).toBe('#deploys')
|
|
})
|
|
|
|
it('validates patched JSON against a zui schema', async () => {
|
|
const schema = z.object({
|
|
name: z.string(),
|
|
version: z.string(),
|
|
port: z.number().min(1).max(65535),
|
|
debug: z.boolean(),
|
|
})
|
|
|
|
const file: File = {
|
|
path: 'config.json',
|
|
name: 'config.json',
|
|
content: JSON.stringify({ name: 'my-app', version: '1.0.0', port: 3000, debug: false }, null, 2),
|
|
}
|
|
|
|
const result = await zai.patch([file], 'change port to 8080 and set debug to true', { schema }).result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
const parsed = JSON.parse(result.output[0].content)
|
|
|
|
// Schema validation should pass
|
|
const safe = schema.safeParse(parsed)
|
|
expect(safe.success).toBe(true)
|
|
|
|
expect(parsed.port).toBe(8080)
|
|
expect(parsed.debug).toBe(true)
|
|
expect(parsed.name).toBe('my-app')
|
|
expect(parsed.version).toBe('1.0.0')
|
|
})
|
|
|
|
it('rejects non-zui schemas', async () => {
|
|
const schema = {
|
|
_output: undefined,
|
|
safeParse: () => ({ success: true, data: { version: '1.0.0' } }),
|
|
}
|
|
|
|
await expect(
|
|
zai
|
|
.patch([configFile], 'change port to 8080 and set debug to true', {
|
|
schema: schema as any,
|
|
})
|
|
.result()
|
|
).rejects.toThrow('@bpinternal/zui')
|
|
})
|
|
|
|
it('validates patched JSON against a nested zui schema', async () => {
|
|
const schema = z.object({
|
|
database: z.object({
|
|
host: z.string(),
|
|
port: z.number().min(5434),
|
|
credentials: z.object({
|
|
username: z.string(),
|
|
password: z.string().min(1),
|
|
}),
|
|
}),
|
|
features: z.array(z.string()),
|
|
})
|
|
|
|
const file: File = {
|
|
path: 'config.json',
|
|
name: 'config.json',
|
|
content: JSON.stringify(
|
|
{
|
|
database: {
|
|
host: 'localhost',
|
|
port: 5432,
|
|
credentials: { username: 'admin', password: 'secret' },
|
|
},
|
|
features: ['auth', 'logging'],
|
|
},
|
|
null,
|
|
2
|
|
),
|
|
}
|
|
|
|
const result = await zai
|
|
.patch(
|
|
[file],
|
|
'change database host to "db.prod.internal", port to 5433 or higher, and add "caching" to features',
|
|
{
|
|
schema,
|
|
}
|
|
)
|
|
.result()
|
|
|
|
expect(result.output).toHaveLength(1)
|
|
const parsed = JSON.parse(result.output[0].content)
|
|
|
|
const safe = schema.safeParse(parsed)
|
|
expect(safe.success).toBe(true)
|
|
|
|
expect(parsed.database.host).toBe('db.prod.internal')
|
|
expect(parsed.database.port).toBe(5434)
|
|
expect(parsed.database.credentials.username).toBe('admin')
|
|
expect(parsed.database.credentials.password).toBe('secret')
|
|
expect(parsed.features).toContain('auth')
|
|
expect(parsed.features).toContain('logging')
|
|
expect(parsed.features).toContain('caching')
|
|
})
|
|
})
|