| const { |
| ARTIFACT_START, |
| ARTIFACT_END, |
| findAllArtifacts, |
| replaceArtifactContent, |
| } = require('./update'); |
|
|
| const createArtifactText = (options = {}) => { |
| const { content = '', wrapCode = true, isClosed = true, prefix = '', suffix = '' } = options; |
|
|
| const codeBlock = wrapCode ? '```\n' + content + '\n```' : content; |
| const end = isClosed ? `\n${ARTIFACT_END}` : ''; |
|
|
| return `${ARTIFACT_START}${prefix}\n${codeBlock}${end}${suffix}`; |
| }; |
|
|
| describe('findAllArtifacts', () => { |
| test('should return empty array for message with no artifacts', () => { |
| const message = { |
| content: [ |
| { |
| type: 'text', |
| text: 'No artifacts here', |
| }, |
| ], |
| }; |
| expect(findAllArtifacts(message)).toEqual([]); |
| }); |
|
|
| test('should find artifacts in content parts', () => { |
| const message = { |
| content: [ |
| { type: 'text', text: createArtifactText({ content: 'content1' }) }, |
| { type: 'text', text: createArtifactText({ content: 'content2' }) }, |
| ], |
| }; |
|
|
| const result = findAllArtifacts(message); |
| expect(result).toHaveLength(2); |
| expect(result[0].source).toBe('content'); |
| expect(result[1].partIndex).toBe(1); |
| }); |
|
|
| test('should find artifacts in message.text when content is empty', () => { |
| const artifact1 = createArtifactText({ content: 'text1' }); |
| const artifact2 = createArtifactText({ content: 'text2' }); |
| const message = { text: [artifact1, artifact2].join('\n') }; |
|
|
| const result = findAllArtifacts(message); |
| expect(result).toHaveLength(2); |
| expect(result[0].source).toBe('text'); |
| }); |
|
|
| test('should handle unclosed artifacts', () => { |
| const message = { |
| text: createArtifactText({ content: 'unclosed', isClosed: false }), |
| }; |
| const result = findAllArtifacts(message); |
| expect(result[0].end).toBe(message.text.length); |
| }); |
|
|
| test('should handle multiple artifacts in single part', () => { |
| const artifact1 = createArtifactText({ content: 'first' }); |
| const artifact2 = createArtifactText({ content: 'second' }); |
| const message = { |
| content: [ |
| { |
| type: 'text', |
| text: [artifact1, artifact2].join('\n'), |
| }, |
| ], |
| }; |
|
|
| const result = findAllArtifacts(message); |
| expect(result).toHaveLength(2); |
| expect(result[1].start).toBeGreaterThan(result[0].end); |
| }); |
| }); |
|
|
| describe('replaceArtifactContent', () => { |
| const createTestArtifact = (content, options) => { |
| const text = createArtifactText({ content, ...options }); |
| return { |
| start: 0, |
| end: text.length, |
| text, |
| source: 'text', |
| }; |
| }; |
|
|
| test('should replace content within artifact boundaries', () => { |
| const original = "console.log('hello')"; |
| const artifact = createTestArtifact(original); |
| const updated = "console.log('updated')"; |
|
|
| const result = replaceArtifactContent(artifact.text, artifact, original, updated); |
| expect(result).toContain(updated); |
| expect(result).toMatch(ARTIFACT_START); |
| expect(result).toMatch(ARTIFACT_END); |
| }); |
|
|
| test('should return null when original not found', () => { |
| const artifact = createTestArtifact('function test() {}'); |
| const result = replaceArtifactContent(artifact.text, artifact, 'missing', 'updated'); |
| expect(result).toBeNull(); |
| }); |
|
|
| test('should handle dedented content', () => { |
| const original = 'function test() {'; |
| const artifact = createTestArtifact(original); |
| const updated = 'function updated() {'; |
|
|
| const result = replaceArtifactContent(artifact.text, artifact, original, updated); |
| expect(result).toContain(updated); |
| }); |
|
|
| test('should preserve text outside artifact', () => { |
| const artifactContent = createArtifactText({ content: 'original' }); |
| const fullText = `prefix\n${artifactContent}\nsuffix`; |
| const artifact = createTestArtifact('original', { |
| prefix: 'prefix\n', |
| suffix: '\nsuffix', |
| }); |
|
|
| const result = replaceArtifactContent(fullText, artifact, 'original', 'updated'); |
| expect(result).toMatch(/^prefix/); |
| expect(result).toMatch(/suffix$/); |
| }); |
|
|
| test('should handle replacement at artifact boundaries', () => { |
| const original = 'console.log("hello")'; |
| const updated = 'console.log("updated")'; |
|
|
| const artifactText = `${ARTIFACT_START}\n${original}\n${ARTIFACT_END}`; |
| const artifact = { |
| start: 0, |
| end: artifactText.length, |
| text: artifactText, |
| source: 'text', |
| }; |
|
|
| const result = replaceArtifactContent(artifactText, artifact, original, updated); |
|
|
| expect(result).toBe(`${ARTIFACT_START}\n${updated}\n${ARTIFACT_END}`); |
| }); |
| }); |
|
|
| describe('replaceArtifactContent with shared text', () => { |
| test('should replace correct artifact when text is shared', () => { |
| const artifactContent = ' hi '; |
| const sharedText = `LOREM IPSUM |
| |
| :::artifact{identifier="calculator" type="application/vnd.react" title="Calculator"} |
| \`\`\` |
| ${artifactContent} |
| \`\`\` |
| ::: |
| |
| LOREM IPSUM |
| |
| :::artifact{identifier="calculator2" type="application/vnd.react" title="Calculator"} |
| \`\`\` |
| ${artifactContent} |
| \`\`\` |
| :::`; |
|
|
| const message = { text: sharedText }; |
| const artifacts = findAllArtifacts(message); |
| expect(artifacts).toHaveLength(2); |
|
|
| const targetArtifact = artifacts[1]; |
| const updatedContent = ' updated content '; |
| const result = replaceArtifactContent( |
| sharedText, |
| targetArtifact, |
| artifactContent, |
| updatedContent, |
| ); |
|
|
| |
| expect(result).toContain(artifactContent); |
| expect(result).toContain(updatedContent); |
| expect(result.indexOf(updatedContent)).toBeGreaterThan(result.indexOf(artifactContent)); |
| }); |
|
|
| const codeExample = ` |
| function greetPerson(name) { |
| return \`Hello, \${name}! Welcome to JavaScript programming.\`; |
| } |
| |
| const personName = "Alice"; |
| const greeting = greetPerson(personName); |
| console.log(greeting);`; |
|
|
| test('should handle random number of artifacts in content array', () => { |
| const numArtifacts = 5; |
| const targetIndex = 2; |
|
|
| |
| const contentParts = Array.from({ length: numArtifacts }, (_, i) => ({ |
| type: 'text', |
| text: createArtifactText({ |
| content: `content-${i}`, |
| wrapCode: true, |
| prefix: i > 0 ? '\n' : '', |
| }), |
| })); |
|
|
| const message = { content: contentParts }; |
| const artifacts = findAllArtifacts(message); |
| expect(artifacts).toHaveLength(numArtifacts); |
|
|
| const targetArtifact = artifacts[targetIndex]; |
| const originalContent = `content-${targetIndex}`; |
| const updatedContent = 'updated-content'; |
|
|
| const result = replaceArtifactContent( |
| contentParts[targetIndex].text, |
| targetArtifact, |
| originalContent, |
| updatedContent, |
| ); |
|
|
| |
| expect(result).toContain(updatedContent); |
| expect(result).not.toContain(originalContent); |
| expect(result).toMatch( |
| new RegExp(`${ARTIFACT_START}.*${updatedContent}.*${ARTIFACT_END}`, 's'), |
| ); |
| }); |
|
|
| test('should handle artifacts with identical content but different metadata in content array', () => { |
| const contentParts = [ |
| { |
| type: 'text', |
| text: createArtifactText({ |
| wrapCode: true, |
| content: codeExample, |
| prefix: '{id="1", title="First"}', |
| }), |
| }, |
| { |
| type: 'text', |
| text: createArtifactText({ |
| wrapCode: true, |
| content: codeExample, |
| prefix: '{id="2", title="Second"}', |
| }), |
| }, |
| ]; |
|
|
| const message = { content: contentParts }; |
| const artifacts = findAllArtifacts(message); |
|
|
| |
| const targetArtifact = artifacts[1]; |
| const result = replaceArtifactContent( |
| contentParts[1].text, |
| targetArtifact, |
| codeExample, |
| 'updated content', |
| ); |
| expect(result).toMatch(/id="2".*updated content/s); |
| expect(result).toMatch(new RegExp(`${ARTIFACT_START}.*updated content.*${ARTIFACT_END}`, 's')); |
| }); |
|
|
| test('should handle empty content in artifact without code blocks', () => { |
| const artifactText = `${ARTIFACT_START}\n\n${ARTIFACT_END}`; |
| const artifact = { |
| start: 0, |
| end: artifactText.length, |
| text: artifactText, |
| source: 'text', |
| }; |
|
|
| const result = replaceArtifactContent(artifactText, artifact, '', 'new content'); |
| expect(result).toBe(`${ARTIFACT_START}\nnew content\n${ARTIFACT_END}`); |
| }); |
|
|
| test('should handle empty content in artifact with code blocks', () => { |
| const artifactText = createArtifactText({ content: '' }); |
| const artifact = { |
| start: 0, |
| end: artifactText.length, |
| text: artifactText, |
| source: 'text', |
| }; |
|
|
| const result = replaceArtifactContent(artifactText, artifact, '', 'new content'); |
| expect(result).toMatch(/```\nnew content\n```/); |
| }); |
|
|
| test('should handle content with trailing newline in code blocks', () => { |
| const contentWithNewline = 'console.log("test")\n'; |
| const message = { |
| text: `Some prefix text\n${createArtifactText({ |
| content: contentWithNewline, |
| })}\nSome suffix text`, |
| }; |
|
|
| const artifacts = findAllArtifacts(message); |
| expect(artifacts).toHaveLength(1); |
|
|
| const result = replaceArtifactContent( |
| message.text, |
| artifacts[0], |
| contentWithNewline, |
| 'updated content', |
| ); |
|
|
| |
| expect(result).toContain('```\nupdated content\n```'); |
| |
| expect(result).toMatch(/^Some prefix text\n/); |
| expect(result).toMatch(/\nSome suffix text$/); |
| |
| expect(result).not.toContain('\n\n```'); |
| expect(result).not.toContain('```\n\n'); |
| }); |
|
|
| describe('incomplete artifacts', () => { |
| test('should handle incomplete artifacts (missing closing ::: and ```)', () => { |
| const original = `<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| <title>Pomodoro</title> |
| <meta name="description" content="A single-file Pomodoro timer with logs, charts, sounds, and dark mode." /> |
| <style> |
| :root{`; |
|
|
| const prefix = `Awesome idea! I'll deliver a complete single-file HTML app called "Pomodoro" with: |
| - Custom session/break durations |
| |
| You can save this as pomodoro.html and open it directly in your browser. |
| |
| `; |
|
|
| |
| const incompleteArtifact = `${ARTIFACT_START}{identifier="pomodoro-single-file-app" type="text/html" title="Pomodoro — Single File App"} |
| \`\`\` |
| ${original}`; |
|
|
| const fullText = prefix + incompleteArtifact; |
| const message = { text: fullText }; |
| const artifacts = findAllArtifacts(message); |
|
|
| expect(artifacts).toHaveLength(1); |
| expect(artifacts[0].end).toBe(fullText.length); |
|
|
| const updated = original.replace('Pomodoro</title>', 'Pomodoro</title>UPDATED'); |
| const result = replaceArtifactContent(fullText, artifacts[0], original, updated); |
|
|
| expect(result).not.toBeNull(); |
| expect(result).toContain('UPDATED'); |
| expect(result).toContain(prefix); |
| |
| expect(result).not.toMatch(/:::\s*$/); |
| }); |
|
|
| test('should handle incomplete artifacts with only opening code block', () => { |
| const original = 'function hello() { console.log("world"); }'; |
| const incompleteArtifact = `${ARTIFACT_START}{id="test"}\n\`\`\`\n${original}`; |
|
|
| const message = { text: incompleteArtifact }; |
| const artifacts = findAllArtifacts(message); |
|
|
| expect(artifacts).toHaveLength(1); |
|
|
| const updated = 'function hello() { console.log("UPDATED"); }'; |
| const result = replaceArtifactContent(incompleteArtifact, artifacts[0], original, updated); |
|
|
| expect(result).not.toBeNull(); |
| expect(result).toContain('UPDATED'); |
| }); |
|
|
| test('should handle incomplete artifacts without code blocks', () => { |
| const original = 'Some plain text content'; |
| const incompleteArtifact = `${ARTIFACT_START}{id="test"}\n${original}`; |
|
|
| const message = { text: incompleteArtifact }; |
| const artifacts = findAllArtifacts(message); |
|
|
| expect(artifacts).toHaveLength(1); |
|
|
| const updated = 'Some UPDATED text content'; |
| const result = replaceArtifactContent(incompleteArtifact, artifacts[0], original, updated); |
|
|
| expect(result).not.toBeNull(); |
| expect(result).toContain('UPDATED'); |
| }); |
| }); |
|
|
| describe('regression tests for edge cases', () => { |
| test('should still handle complete artifacts correctly', () => { |
| |
| const original = 'console.log("test");'; |
| const artifact = createArtifactText({ content: original }); |
|
|
| const message = { text: artifact }; |
| const artifacts = findAllArtifacts(message); |
|
|
| expect(artifacts).toHaveLength(1); |
|
|
| const updated = 'console.log("updated");'; |
| const result = replaceArtifactContent(artifact, artifacts[0], original, updated); |
|
|
| expect(result).not.toBeNull(); |
| expect(result).toContain(updated); |
| expect(result).toContain(ARTIFACT_END); |
| expect(result).toMatch(/```\nconsole\.log\("updated"\);\n```/); |
| }); |
|
|
| test('should handle multiple complete artifacts', () => { |
| |
| const content1 = 'First artifact'; |
| const content2 = 'Second artifact'; |
| const text = `${createArtifactText({ content: content1 })}\n\n${createArtifactText({ content: content2 })}`; |
|
|
| const message = { text }; |
| const artifacts = findAllArtifacts(message); |
|
|
| expect(artifacts).toHaveLength(2); |
|
|
| |
| const result1 = replaceArtifactContent(text, artifacts[0], content1, 'First UPDATED'); |
| expect(result1).not.toBeNull(); |
| expect(result1).toContain('First UPDATED'); |
| expect(result1).toContain(content2); |
|
|
| |
| const result2 = replaceArtifactContent(text, artifacts[1], content2, 'Second UPDATED'); |
| expect(result2).not.toBeNull(); |
| expect(result2).toContain(content1); |
| expect(result2).toContain('Second UPDATED'); |
| }); |
|
|
| test('should not mistake ::: at position 0 for artifact end in complete artifacts', () => { |
| |
| const original = 'test content'; |
| |
| const artifact = `${ARTIFACT_START}\n\`\`\`\n${original}\n\`\`\`\n${ARTIFACT_END}`; |
|
|
| const message = { text: artifact }; |
| const artifacts = findAllArtifacts(message); |
|
|
| expect(artifacts).toHaveLength(1); |
|
|
| const updated = 'updated content'; |
| const result = replaceArtifactContent(artifact, artifacts[0], original, updated); |
|
|
| expect(result).not.toBeNull(); |
| expect(result).toContain(updated); |
| expect(result).toContain(ARTIFACT_END); |
| }); |
|
|
| test('should handle empty artifacts', () => { |
| |
| const artifact = `${ARTIFACT_START}\n${ARTIFACT_END}`; |
|
|
| const message = { text: artifact }; |
| const artifacts = findAllArtifacts(message); |
|
|
| expect(artifacts).toHaveLength(1); |
|
|
| |
| const result = replaceArtifactContent(artifact, artifacts[0], 'something', 'updated'); |
| expect(result).toBeNull(); |
| }); |
|
|
| test('should preserve whitespace and formatting in complete artifacts', () => { |
| const original = ` function test() { |
| return { |
| value: 42 |
| }; |
| }`; |
| const artifact = createArtifactText({ content: original }); |
|
|
| const message = { text: artifact }; |
| const artifacts = findAllArtifacts(message); |
|
|
| const updated = ` function test() { |
| return { |
| value: 100 |
| }; |
| }`; |
| const result = replaceArtifactContent(artifact, artifacts[0], original, updated); |
|
|
| expect(result).not.toBeNull(); |
| expect(result).toContain('value: 100'); |
| |
| expect(result).toMatch( |
| /```\n {2}function test\(\) \{\n {4}return \{\n {6}value: 100\n {4}\};\n {2}\}\n```/, |
| ); |
| }); |
| }); |
| }); |
|
|