diff --git a/packages/app/src/cli/services/build/steps/include-assets-step.test.ts b/packages/app/src/cli/services/build/steps/include-assets-step.test.ts index e493f046643..150ff59184d 100644 --- a/packages/app/src/cli/services/build/steps/include-assets-step.test.ts +++ b/packages/app/src/cli/services/build/steps/include-assets-step.test.ts @@ -2,9 +2,8 @@ import {executeIncludeAssetsStep} from './include-assets-step.js' import {LifecycleStep, BuildContext} from '../client-steps.js' import {ExtensionInstance} from '../../../models/extensions/extension-instance.js' import {describe, expect, test, vi, beforeEach} from 'vitest' -import * as fs from '@shopify/cli-kit/node/fs' - -vi.mock('@shopify/cli-kit/node/fs') +import {inTemporaryDirectory, writeFile, mkdir, fileExists, readFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' describe('executeIncludeAssetsStep', () => { let mockExtension: ExtensionInstance @@ -15,9 +14,17 @@ describe('executeIncludeAssetsStep', () => { beforeEach(() => { mockStdout = {write: vi.fn()} mockStderr = {write: vi.fn()} + }) + + async function setupTestEnvironment(tmpDir: string) { + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + mockExtension = { - directory: '/test/extension', - outputPath: '/test/output/extension.js', + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), } as ExtensionInstance mockContext = { @@ -25,1425 +32,1507 @@ describe('executeIncludeAssetsStep', () => { options: { stdout: mockStdout, stderr: mockStderr, - app: {directory: '/test'} as any, + app: {directory: tmpDir} as any, environment: 'production', }, stepResults: new Map(), } - }) + + return {extensionDir, outputDir} + } describe('static entries', () => { test('copies directory under its own name when no destination is given', async () => { - // Given - vi.mocked(fs.fileExists).mockResolvedValue(true) - vi.mocked(fs.isDirectory).mockResolvedValue(true) - vi.mocked(fs.copyDirectoryContents).mockResolvedValue() - vi.mocked(fs.glob).mockResolvedValue(['index.html', 'assets/logo.png']) - - const step: LifecycleStep = { - id: 'copy-dist', - name: 'Copy Dist', - type: 'include_assets', - config: { - inclusions: [{type: 'static', source: 'dist'}], - }, - } - - // When - const result = await executeIncludeAssetsStep(step, mockContext) - - // Then — directory is placed under its own name, not merged into output root - expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/dist', '/test/output/dist') - expect(result.filesCopied).toBe(2) - expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining('Included dist')) + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + const distDir = joinPath(extensionDir, 'dist') + await mkdir(distDir) + await writeFile(joinPath(distDir, 'index.html'), 'html') + await mkdir(joinPath(distDir, 'assets')) + await writeFile(joinPath(distDir, 'assets/logo.png'), 'png') + + const step: LifecycleStep = { + id: 'copy-dist', + name: 'Copy Dist', + type: 'include_assets', + config: { + inclusions: [{type: 'static', source: 'dist'}], + }, + } + + // When + const result = await executeIncludeAssetsStep(step, mockContext) + + // Then — directory is placed under its own name, not merged into output root + await expect(fileExists(joinPath(outputDir, 'dist/index.html'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'dist/assets/logo.png'))).resolves.toBe(true) + expect(result.filesCopied).toBe(2) + expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining('Included dist')) + }) }) test('throws when source directory does not exist', async () => { - // Given - vi.mocked(fs.fileExists).mockResolvedValue(false) - - const step: LifecycleStep = { - id: 'copy-dist', - name: 'Copy Dist', - type: 'include_assets', - config: { - inclusions: [{type: 'static', source: 'dist'}], - }, - } - - // When/Then - await expect(executeIncludeAssetsStep(step, mockContext)).rejects.toThrow('Source does not exist') + await inTemporaryDirectory(async (tmpDir) => { + // Given + await setupTestEnvironment(tmpDir) + + const step: LifecycleStep = { + id: 'copy-dist', + name: 'Copy Dist', + type: 'include_assets', + config: { + inclusions: [{type: 'static', source: 'dist'}], + }, + } + + // When/Then + await expect(executeIncludeAssetsStep(step, mockContext)).rejects.toThrow('Source does not exist') + }) }) test('copies file to explicit destination path', async () => { - // Given - vi.mocked(fs.fileExists).mockResolvedValue(true) - vi.mocked(fs.copyFile).mockResolvedValue() - vi.mocked(fs.mkdir).mockResolvedValue() - - const step: LifecycleStep = { - id: 'copy-icon', - name: 'Copy Icon', - type: 'include_assets', - config: { - inclusions: [{type: 'static', source: 'src/icon.png', destination: 'assets/icon.png'}], - }, - } - - // When - const result = await executeIncludeAssetsStep(step, mockContext) - - // Then - expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/src/icon.png', '/test/output/assets/icon.png') - expect(result.filesCopied).toBe(1) - expect(mockStdout.write).toHaveBeenCalledWith('Included src/icon.png\n') + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + const srcDir = joinPath(extensionDir, 'src') + await mkdir(srcDir) + await writeFile(joinPath(srcDir, 'icon.png'), 'icon') + + const step: LifecycleStep = { + id: 'copy-icon', + name: 'Copy Icon', + type: 'include_assets', + config: { + inclusions: [{type: 'static', source: 'src/icon.png', destination: 'assets/icon.png'}], + }, + } + + // When + const result = await executeIncludeAssetsStep(step, mockContext) + + // Then + await expect(fileExists(joinPath(outputDir, 'assets/icon.png'))).resolves.toBe(true) + expect(result.filesCopied).toBe(1) + expect(mockStdout.write).toHaveBeenCalledWith('Included src/icon.png\n') + }) }) test('throws when source file does not exist (with destination)', async () => { - // Given - vi.mocked(fs.fileExists).mockResolvedValue(false) - - const step: LifecycleStep = { - id: 'copy-icon', - name: 'Copy Icon', - type: 'include_assets', - config: { - inclusions: [{type: 'static', source: 'src/missing.png', destination: 'assets/missing.png'}], - }, - } - - // When/Then - await expect(executeIncludeAssetsStep(step, mockContext)).rejects.toThrow('Source does not exist') + await inTemporaryDirectory(async (tmpDir) => { + // Given + await setupTestEnvironment(tmpDir) + + const step: LifecycleStep = { + id: 'copy-icon', + name: 'Copy Icon', + type: 'include_assets', + config: { + inclusions: [{type: 'static', source: 'src/missing.png', destination: 'assets/missing.png'}], + }, + } + + // When/Then + await expect(executeIncludeAssetsStep(step, mockContext)).rejects.toThrow('Source does not exist') + }) }) test('handles multiple static entries in inclusions', async () => { - // Given - vi.mocked(fs.fileExists).mockResolvedValue(true) - vi.mocked(fs.isDirectory).mockResolvedValueOnce(true).mockResolvedValueOnce(false) - vi.mocked(fs.copyDirectoryContents).mockResolvedValue() - vi.mocked(fs.copyFile).mockResolvedValue() - vi.mocked(fs.mkdir).mockResolvedValue() - vi.mocked(fs.glob).mockResolvedValue(['index.html']) - - const step: LifecycleStep = { - id: 'copy-mixed', - name: 'Copy Mixed', - type: 'include_assets', - config: { - inclusions: [ - {type: 'static', source: 'dist'}, - {type: 'static', source: 'src/icon.png', destination: 'assets/icon.png'}, - ], - }, - } - - // When - const result = await executeIncludeAssetsStep(step, mockContext) - - // Then - expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/dist', '/test/output/dist') - expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/src/icon.png', '/test/output/assets/icon.png') - expect(result.filesCopied).toBe(2) + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + const distDir = joinPath(extensionDir, 'dist') + await mkdir(distDir) + await writeFile(joinPath(distDir, 'index.html'), 'html') + + const srcDir = joinPath(extensionDir, 'src') + await mkdir(srcDir) + await writeFile(joinPath(srcDir, 'icon.png'), 'icon') + + const step: LifecycleStep = { + id: 'copy-mixed', + name: 'Copy Mixed', + type: 'include_assets', + config: { + inclusions: [ + {type: 'static', source: 'dist'}, + {type: 'static', source: 'src/icon.png', destination: 'assets/icon.png'}, + ], + }, + } + + // When + const result = await executeIncludeAssetsStep(step, mockContext) + + // Then + await expect(fileExists(joinPath(outputDir, 'dist/index.html'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'assets/icon.png'))).resolves.toBe(true) + expect(result.filesCopied).toBe(2) + }) }) test('copies a file to output root when source is a file and no destination is given', async () => { - // Given - vi.mocked(fs.fileExists).mockResolvedValue(true) - vi.mocked(fs.isDirectory).mockResolvedValue(false) - vi.mocked(fs.copyFile).mockResolvedValue() - vi.mocked(fs.mkdir).mockResolvedValue() - - const step: LifecycleStep = { - id: 'copy-readme', - name: 'Copy README', - type: 'include_assets', - config: { - inclusions: [{type: 'static', source: 'README.md'}], - }, - } - - // When - const result = await executeIncludeAssetsStep(step, mockContext) - - // Then - expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/README.md', '/test/output/README.md') - expect(result.filesCopied).toBe(1) - expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining('Included README.md')) + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + await writeFile(joinPath(extensionDir, 'README.md'), 'readme') + + const step: LifecycleStep = { + id: 'copy-readme', + name: 'Copy README', + type: 'include_assets', + config: { + inclusions: [{type: 'static', source: 'README.md'}], + }, + } + + // When + const result = await executeIncludeAssetsStep(step, mockContext) + + // Then + await expect(fileExists(joinPath(outputDir, 'README.md'))).resolves.toBe(true) + expect(result.filesCopied).toBe(1) + expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining('Included README.md')) + }) }) test('copies a directory to explicit destination path and returns actual file count', async () => { - // Given - vi.mocked(fs.fileExists).mockResolvedValue(true) - vi.mocked(fs.isDirectory).mockResolvedValue(true) - vi.mocked(fs.copyDirectoryContents).mockResolvedValue() - vi.mocked(fs.glob).mockResolvedValue(['a.js', 'b.js', 'c.js']) - vi.mocked(fs.mkdir).mockResolvedValue() - - const step: LifecycleStep = { - id: 'copy-dist', - name: 'Copy Dist', - type: 'include_assets', - config: { - inclusions: [{type: 'static', source: 'dist', destination: 'assets/dist'}], - }, - } - - // When - const result = await executeIncludeAssetsStep(step, mockContext) - - // Then — uses copyDirectoryContents (not copyFile) and counts actual files via glob - expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/dist', '/test/output/assets/dist') - expect(fs.copyFile).not.toHaveBeenCalled() - expect(result.filesCopied).toBe(3) - expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining('Included dist')) + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + const distDir = joinPath(extensionDir, 'dist') + await mkdir(distDir) + await writeFile(joinPath(distDir, 'a.js'), 'a') + await writeFile(joinPath(distDir, 'b.js'), 'b') + await writeFile(joinPath(distDir, 'c.js'), 'c') + + const step: LifecycleStep = { + id: 'copy-dist', + name: 'Copy Dist', + type: 'include_assets', + config: { + inclusions: [{type: 'static', source: 'dist', destination: 'assets/dist'}], + }, + } + + // When + const result = await executeIncludeAssetsStep(step, mockContext) + + // Then + await expect(fileExists(joinPath(outputDir, 'assets/dist/a.js'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'assets/dist/b.js'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'assets/dist/c.js'))).resolves.toBe(true) + expect(result.filesCopied).toBe(3) + expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining('Included dist')) + }) }) }) describe('configKey entries', () => { test('copies directory contents for resolved configKey', async () => { - // Given - const contextWithConfig = { - ...mockContext, - extension: { - ...mockExtension, - configuration: {static_root: 'public'}, - } as unknown as ExtensionInstance, - } - - vi.mocked(fs.fileExists).mockResolvedValue(true) - vi.mocked(fs.isDirectory).mockResolvedValue(true) - vi.mocked(fs.copyDirectoryContents).mockResolvedValue() - vi.mocked(fs.glob).mockResolvedValue(['index.html', 'logo.png']) - - const step: LifecycleStep = { - id: 'copy-static', - name: 'Copy Static', - type: 'include_assets', - config: { - inclusions: [{type: 'configKey', key: 'static_root'}], - }, - } - - // When - const result = await executeIncludeAssetsStep(step, contextWithConfig) - - // Then - expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/public', '/test/output') - expect(result.filesCopied).toBe(2) + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + const publicDir = joinPath(extensionDir, 'public') + await mkdir(publicDir) + await writeFile(joinPath(publicDir, 'index.html'), 'html') + await writeFile(joinPath(publicDir, 'logo.png'), 'png') + + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {static_root: 'public'}, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'copy-static', + name: 'Copy Static', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', key: 'static_root'}], + }, + } + + // When + const result = await executeIncludeAssetsStep(step, contextWithConfig) + + // Then + await expect(fileExists(joinPath(outputDir, 'index.html'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'logo.png'))).resolves.toBe(true) + expect(result.filesCopied).toBe(2) + }) }) test('skips silently when configKey is absent from config', async () => { - // Given — configuration has no static_root - const contextWithoutConfig = { - ...mockContext, - extension: { - ...mockExtension, - configuration: {}, - } as unknown as ExtensionInstance, - } - - const step: LifecycleStep = { - id: 'copy-static', - name: 'Copy Static', - type: 'include_assets', - config: { - inclusions: [{type: 'configKey', key: 'static_root'}], - }, - } - - // When - const result = await executeIncludeAssetsStep(step, contextWithoutConfig) - - // Then — no error, no copies - expect(result.filesCopied).toBe(0) - expect(fs.copyDirectoryContents).not.toHaveBeenCalled() + await inTemporaryDirectory(async (tmpDir) => { + // Given + await setupTestEnvironment(tmpDir) + const contextWithoutConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {}, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'copy-static', + name: 'Copy Static', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', key: 'static_root'}], + }, + } + + // When + const result = await executeIncludeAssetsStep(step, contextWithoutConfig) + + // Then — no error, no copies + expect(result.filesCopied).toBe(0) + }) }) test('throws an error when the referenced file does not exist on disk', async () => { - const contextWithConfig = { - ...mockContext, - extension: { - ...mockExtension, - configuration: {static_root: 'nonexistent'}, - } as unknown as ExtensionInstance, - } - - vi.mocked(fs.fileExists).mockResolvedValue(false) - - const step: LifecycleStep = { - id: 'copy-static', - name: 'Copy Static', - type: 'include_assets', - config: { - inclusions: [{type: 'configKey', key: 'static_root'}], - }, - } - - await expect(executeIncludeAssetsStep(step, contextWithConfig)).rejects.toThrow( - `Couldn't find /test/extension/nonexistent\n Please check the path 'nonexistent' in your configuration`, - ) + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir} = await setupTestEnvironment(tmpDir) + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {static_root: 'nonexistent'}, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'copy-static', + name: 'Copy Static', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', key: 'static_root'}], + }, + } + + await expect(executeIncludeAssetsStep(step, contextWithConfig)).rejects.toThrow( + `Couldn't find ${joinPath(extensionDir, 'nonexistent')}\n Please check the path 'nonexistent' in your configuration`, + ) + }) }) test('throws an error when an intent schema file does not exist on disk', async () => { - const contextWithConfig = { - ...mockContext, - extension: { - ...mockExtension, - configuration: { - targeting: [ - { - target: 'admin.app.intent.link', - intents: [{type: 'application/email', action: 'edit', schema: './email-schema.json'}], - }, - ], + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir} = await setupTestEnvironment(tmpDir) + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + targeting: [ + { + target: 'admin.app.intent.link', + intents: [{type: 'application/email', action: 'edit', schema: './email-schema.json'}], + }, + ], + }, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'copy-intents', + name: 'Copy Intents', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', key: 'targeting[].intents[].schema'}], }, - } as unknown as ExtensionInstance, - } - - vi.mocked(fs.fileExists).mockResolvedValue(false) - - const step: LifecycleStep = { - id: 'copy-intents', - name: 'Copy Intents', - type: 'include_assets', - config: { - inclusions: [{type: 'configKey', key: 'targeting[].intents[].schema'}], - }, - } - - await expect(executeIncludeAssetsStep(step, contextWithConfig)).rejects.toThrow( - `Couldn't find /test/extension/email-schema.json\n Please check the path './email-schema.json' in your configuration`, - ) + } + + await expect(executeIncludeAssetsStep(step, contextWithConfig)).rejects.toThrow( + `Couldn't find ${joinPath(extensionDir, 'email-schema.json')}\n Please check the path './email-schema.json' in your configuration`, + ) + }) }) test('does not throw when intent config key is absent', async () => { - const contextWithConfig = { - ...mockContext, - extension: { - ...mockExtension, - configuration: { - targeting: [{target: 'admin.app.intent.link'}], + await inTemporaryDirectory(async (tmpDir) => { + // Given + await setupTestEnvironment(tmpDir) + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + targeting: [{target: 'admin.app.intent.link'}], + }, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'copy-intents', + name: 'Copy Intents', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', key: 'targeting[].intents[].schema'}], }, - } as unknown as ExtensionInstance, - } - - const step: LifecycleStep = { - id: 'copy-intents', - name: 'Copy Intents', - type: 'include_assets', - config: { - inclusions: [{type: 'configKey', key: 'targeting[].intents[].schema'}], - }, - } - - const result = await executeIncludeAssetsStep(step, contextWithConfig) - expect(result.filesCopied).toBe(0) + } + + const result = await executeIncludeAssetsStep(step, contextWithConfig) + expect(result.filesCopied).toBe(0) + }) }) test('overwrites existing file on rebuild', async () => { - const contextWithConfig = { - ...mockContext, - extension: { - ...mockExtension, - configuration: {tools: './tools.json'}, - } as unknown as ExtensionInstance, - } - - vi.mocked(fs.fileExists).mockImplementation(async (path) => { - const pathStr = String(path) - // Source file exists; output file also exists from previous build - return pathStr === '/test/extension/tools.json' || pathStr === '/test/output/tools.json' + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + await writeFile(joinPath(extensionDir, 'tools.json'), 'new content') + await writeFile(joinPath(outputDir, 'tools.json'), 'old content') + + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {tools: './tools.json'}, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'copy-tools', + name: 'Copy Tools', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', key: 'tools'}], + }, + } + + const result = await executeIncludeAssetsStep(step, contextWithConfig) + + // Overwrites the existing file rather than creating tools-1.json + await expect(readFile(joinPath(outputDir, 'tools.json'))).resolves.toBe('new content') + expect(result.filesCopied).toBe(1) }) - vi.mocked(fs.isDirectory).mockResolvedValue(false) - vi.mocked(fs.copyFile).mockResolvedValue() - vi.mocked(fs.mkdir).mockResolvedValue() - - const step: LifecycleStep = { - id: 'copy-tools', - name: 'Copy Tools', - type: 'include_assets', - config: { - inclusions: [{type: 'configKey', key: 'tools'}], - }, - } - - const result = await executeIncludeAssetsStep(step, contextWithConfig) - - // Overwrites the existing file rather than creating tools-1.json - expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/tools.json', '/test/output/tools.json') - expect(result.filesCopied).toBe(1) }) test('renames file to avoid collision when two different sources share the same basename', async () => { - const contextWithConfig = { - ...mockContext, - extension: { - ...mockExtension, - configuration: {tools_a: './a/schema.json', tools_b: './b/schema.json'}, - } as unknown as ExtensionInstance, - } - - vi.mocked(fs.fileExists).mockImplementation(async (path) => { - const pathStr = String(path) - return pathStr === '/test/extension/a/schema.json' || pathStr === '/test/extension/b/schema.json' + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + await mkdir(joinPath(extensionDir, 'a')) + await mkdir(joinPath(extensionDir, 'b')) + await writeFile(joinPath(extensionDir, 'a/schema.json'), 'a') + await writeFile(joinPath(extensionDir, 'b/schema.json'), 'b') + + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {tools_a: './a/schema.json', tools_b: './b/schema.json'}, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'copy-tools', + name: 'Copy Tools', + type: 'include_assets', + config: { + inclusions: [ + {type: 'configKey', key: 'tools_a'}, + {type: 'configKey', key: 'tools_b'}, + ], + }, + } + + const result = await executeIncludeAssetsStep(step, contextWithConfig) + + await expect(readFile(joinPath(outputDir, 'schema.json'))).resolves.toBe('a') + await expect(readFile(joinPath(outputDir, 'schema-1.json'))).resolves.toBe('b') + expect(result.filesCopied).toBe(2) }) - vi.mocked(fs.isDirectory).mockResolvedValue(false) - vi.mocked(fs.copyFile).mockResolvedValue() - vi.mocked(fs.mkdir).mockResolvedValue() - - const step: LifecycleStep = { - id: 'copy-tools', - name: 'Copy Tools', - type: 'include_assets', - config: { - inclusions: [ - {type: 'configKey', key: 'tools_a'}, - {type: 'configKey', key: 'tools_b'}, - ], - }, - } - - const result = await executeIncludeAssetsStep(step, contextWithConfig) - - expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/a/schema.json', '/test/output/schema.json') - expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/b/schema.json', '/test/output/schema-1.json') - expect(result.filesCopied).toBe(2) }) test('resolves array config value and copies each path', async () => { - // Given — static_root is an array - const contextWithArrayConfig = { - ...mockContext, - extension: { - ...mockExtension, - configuration: {static_root: ['public', 'assets']}, - } as unknown as ExtensionInstance, - } - - vi.mocked(fs.fileExists).mockResolvedValue(true) - vi.mocked(fs.isDirectory).mockResolvedValue(true) - vi.mocked(fs.copyDirectoryContents).mockResolvedValue() - vi.mocked(fs.glob).mockResolvedValue(['file.html']) - - const step: LifecycleStep = { - id: 'copy-static', - name: 'Copy Static', - type: 'include_assets', - config: { - inclusions: [{type: 'configKey', key: 'static_root'}], - }, - } - - // When - await executeIncludeAssetsStep(step, contextWithArrayConfig) - - // Then — both paths copied - expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/public', '/test/output') - expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/assets', '/test/output') + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + const publicDir = joinPath(extensionDir, 'public') + const assetsDir = joinPath(extensionDir, 'assets') + await mkdir(publicDir) + await mkdir(assetsDir) + await writeFile(joinPath(publicDir, 'file1.html'), '1') + await writeFile(joinPath(assetsDir, 'file2.html'), '2') + + const contextWithArrayConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {static_root: ['public', 'assets']}, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'copy-static', + name: 'Copy Static', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', key: 'static_root'}], + }, + } + + // When + await executeIncludeAssetsStep(step, contextWithArrayConfig) + + // Then — both paths copied + await expect(fileExists(joinPath(outputDir, 'file1.html'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'file2.html'))).resolves.toBe(true) + }) }) test('resolves nested configKey with [] flatten and collects all leaf values', async () => { - // Given — TOML array-of-tables: extensions[].targeting[].tools - const contextWithNestedConfig = { - ...mockContext, - extension: { - ...mockExtension, - configuration: { - extensions: [ - {targeting: [{tools: 'tools-a.js'}, {tools: 'tools-b.js'}]}, - {targeting: [{tools: 'tools-c.js'}]}, - ], + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + await writeFile(joinPath(extensionDir, 'tools-a.js'), 'a') + await writeFile(joinPath(extensionDir, 'tools-b.js'), 'b') + await writeFile(joinPath(extensionDir, 'tools-c.js'), 'c') + + const contextWithNestedConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + extensions: [ + {targeting: [{tools: 'tools-a.js'}, {tools: 'tools-b.js'}]}, + {targeting: [{tools: 'tools-c.js'}]}, + ], + }, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'copy-tools', + name: 'Copy Tools', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', key: 'extensions[].targeting[].tools'}], }, - } as unknown as ExtensionInstance, - } - - vi.mocked(fs.fileExists).mockImplementation( - async (path) => typeof path === 'string' && path.startsWith('/test/extension'), - ) - vi.mocked(fs.isDirectory).mockResolvedValue(false) - vi.mocked(fs.copyDirectoryContents).mockResolvedValue() - vi.mocked(fs.glob).mockResolvedValue(['file.js']) - vi.mocked(fs.copyFile).mockResolvedValue() - vi.mocked(fs.mkdir).mockResolvedValue() - - const step: LifecycleStep = { - id: 'copy-tools', - name: 'Copy Tools', - type: 'include_assets', - config: { - inclusions: [{type: 'configKey', key: 'extensions[].targeting[].tools'}], - }, - } - - // When - await executeIncludeAssetsStep(step, contextWithNestedConfig) - - // Then — all three tools paths resolved and copied (file paths → copyFile) - expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/tools-a.js', '/test/output/tools-a.js') - expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/tools-b.js', '/test/output/tools-b.js') - expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/tools-c.js', '/test/output/tools-c.js') + } + + // When + await executeIncludeAssetsStep(step, contextWithNestedConfig) + + // Then + await expect(fileExists(joinPath(outputDir, 'tools-a.js'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'tools-b.js'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'tools-c.js'))).resolves.toBe(true) + }) }) test('skips silently when [] flatten key resolves to a non-array', async () => { - // Given — targeting is a plain object, not an array - const contextWithBadConfig = { - ...mockContext, - extension: { - ...mockExtension, - configuration: {extensions: {targeting: {tools: 'tools.js'}}}, - } as unknown as ExtensionInstance, - } - - const step: LifecycleStep = { - id: 'copy-tools', - name: 'Copy Tools', - type: 'include_assets', - config: { - inclusions: [{type: 'configKey', key: 'extensions[].targeting[].tools'}], - }, - } - - // When - const result = await executeIncludeAssetsStep(step, contextWithBadConfig) - - // Then — contract violated, skipped silently - expect(result.filesCopied).toBe(0) - expect(fs.copyDirectoryContents).not.toHaveBeenCalled() + await inTemporaryDirectory(async (tmpDir) => { + // Given + await setupTestEnvironment(tmpDir) + const contextWithBadConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {extensions: {targeting: {tools: 'tools.js'}}}, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'copy-tools', + name: 'Copy Tools', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', key: 'extensions[].targeting[].tools'}], + }, + } + + // When + const result = await executeIncludeAssetsStep(step, contextWithBadConfig) + + // Then — contract violated, skipped silently + expect(result.filesCopied).toBe(0) + }) }) test('handles mixed configKey and source entries in inclusions', async () => { - // Given - const contextWithConfig = { - ...mockContext, - extension: { - ...mockExtension, - configuration: {static_root: 'public'}, - } as unknown as ExtensionInstance, - } - - vi.mocked(fs.fileExists).mockResolvedValue(true) - // Directories have no file extension; files do - vi.mocked(fs.isDirectory).mockImplementation(async (path) => !/\.\w+$/.test(String(path))) - vi.mocked(fs.copyDirectoryContents).mockResolvedValue() - vi.mocked(fs.copyFile).mockResolvedValue() - vi.mocked(fs.mkdir).mockResolvedValue() - vi.mocked(fs.glob).mockResolvedValue(['index.html']) - - const step: LifecycleStep = { - id: 'copy-mixed', - name: 'Copy Mixed', - type: 'include_assets', - config: { - inclusions: [ - {type: 'configKey', key: 'static_root'}, - {type: 'static', source: 'src/icon.png', destination: 'assets/icon.png'}, - ], - }, - } - - // When - const result = await executeIncludeAssetsStep(step, contextWithConfig) - - // Then — directory configKey uses copyDirectoryContents; file static uses copyFile - expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/public', '/test/output') - expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/src/icon.png', '/test/output/assets/icon.png') - expect(result.filesCopied).toBe(2) + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + const publicDir = joinPath(extensionDir, 'public') + await mkdir(publicDir) + await writeFile(joinPath(publicDir, 'index.html'), 'html') + + const srcDir = joinPath(extensionDir, 'src') + await mkdir(srcDir) + await writeFile(joinPath(srcDir, 'icon.png'), 'icon') + + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {static_root: 'public'}, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'copy-mixed', + name: 'Copy Mixed', + type: 'include_assets', + config: { + inclusions: [ + {type: 'configKey', key: 'static_root'}, + {type: 'static', source: 'src/icon.png', destination: 'assets/icon.png'}, + ], + }, + } + + // When + const result = await executeIncludeAssetsStep(step, contextWithConfig) + + // Then + await expect(fileExists(joinPath(outputDir, 'index.html'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'assets/icon.png'))).resolves.toBe(true) + expect(result.filesCopied).toBe(2) + }) }) }) describe('pattern entries', () => { - beforeEach(() => { - // copyByPattern now short-circuits if sourceDir doesn't exist; default true here. - vi.mocked(fs.fileExists).mockResolvedValue(true) - }) - test('copies files matching include patterns', async () => { - // Given - vi.mocked(fs.glob).mockResolvedValue(['/test/extension/public/logo.png', '/test/extension/public/style.css']) - vi.mocked(fs.copyFile).mockResolvedValue() - vi.mocked(fs.mkdir).mockResolvedValue() - - const step: LifecycleStep = { - id: 'copy-public', - name: 'Copy Public', - type: 'include_assets', - config: { - inclusions: [{type: 'pattern', baseDir: 'public', include: ['**/*']}], - }, - } - - // When - const result = await executeIncludeAssetsStep(step, mockContext) - - // Then - expect(result.filesCopied).toBe(2) - expect(fs.copyFile).toHaveBeenCalledTimes(2) + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + const publicDir = joinPath(extensionDir, 'public') + await mkdir(publicDir) + await writeFile(joinPath(publicDir, 'logo.png'), 'png') + await writeFile(joinPath(publicDir, 'style.css'), 'css') + + const step: LifecycleStep = { + id: 'copy-public', + name: 'Copy Public', + type: 'include_assets', + config: { + inclusions: [{type: 'pattern', baseDir: 'public', include: ['**/*']}], + }, + } + + // When + const result = await executeIncludeAssetsStep(step, mockContext) + + // Then + expect(result.filesCopied).toBe(2) + await expect(fileExists(joinPath(outputDir, 'logo.png'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'style.css'))).resolves.toBe(true) + }) }) test('uses extension directory as source when source is omitted', async () => { - // Given - vi.mocked(fs.glob).mockResolvedValue(['/test/extension/index.js', '/test/extension/manifest.json']) - vi.mocked(fs.copyFile).mockResolvedValue() - vi.mocked(fs.mkdir).mockResolvedValue() - - const step: LifecycleStep = { - id: 'copy-root', - name: 'Copy Root', - type: 'include_assets', - config: { - inclusions: [{type: 'pattern', include: ['*.js', '*.json']}], - }, - } - - // When - await executeIncludeAssetsStep(step, mockContext) - - // Then — glob is called with extension.directory as cwd - expect(fs.glob).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({cwd: '/test/extension'})) + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + await writeFile(joinPath(extensionDir, 'index.js'), 'js') + await writeFile(joinPath(extensionDir, 'manifest.json'), 'json') + + const step: LifecycleStep = { + id: 'copy-root', + name: 'Copy Root', + type: 'include_assets', + config: { + inclusions: [{type: 'pattern', include: ['*.js', '*.json']}], + }, + } + + // When + await executeIncludeAssetsStep(step, mockContext) + + // Then + await expect(fileExists(joinPath(outputDir, 'index.js'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'manifest.json'))).resolves.toBe(true) + }) }) test('respects ignore patterns', async () => { - // Given - vi.mocked(fs.glob).mockResolvedValue(['/test/extension/public/style.css']) - vi.mocked(fs.copyFile).mockResolvedValue() - vi.mocked(fs.mkdir).mockResolvedValue() - - const step: LifecycleStep = { - id: 'copy-public', - name: 'Copy Public', - type: 'include_assets', - config: { - inclusions: [{type: 'pattern', baseDir: 'public', ignore: ['**/*.png']}], - }, - } - - // When - await executeIncludeAssetsStep(step, mockContext) - - // Then - expect(fs.glob).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({ignore: ['**/*.png']})) + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + const publicDir = joinPath(extensionDir, 'public') + await mkdir(publicDir) + await writeFile(joinPath(publicDir, 'logo.png'), 'png') + await writeFile(joinPath(publicDir, 'style.css'), 'css') + + const step: LifecycleStep = { + id: 'copy-public', + name: 'Copy Public', + type: 'include_assets', + config: { + inclusions: [{type: 'pattern', baseDir: 'public', ignore: ['**/*.png']}], + }, + } + + // When + await executeIncludeAssetsStep(step, mockContext) + + // Then + await expect(fileExists(joinPath(outputDir, 'logo.png'))).resolves.toBe(false) + await expect(fileExists(joinPath(outputDir, 'style.css'))).resolves.toBe(true) + }) }) test('copies to destination subdirectory when specified', async () => { - // Given - vi.mocked(fs.glob).mockResolvedValue(['/test/extension/public/logo.png']) - vi.mocked(fs.copyFile).mockResolvedValue() - vi.mocked(fs.mkdir).mockResolvedValue() - - const step: LifecycleStep = { - id: 'copy-public', - name: 'Copy Public', - type: 'include_assets', - config: { - inclusions: [{type: 'pattern', baseDir: 'public', destination: 'static'}], - }, - } - - // When - await executeIncludeAssetsStep(step, mockContext) - - // Then - expect(fs.glob).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({cwd: '/test/extension/public'})) - expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/public/logo.png', '/test/output/static/logo.png') + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + const publicDir = joinPath(extensionDir, 'public') + await mkdir(publicDir) + await writeFile(joinPath(publicDir, 'logo.png'), 'png') + + const step: LifecycleStep = { + id: 'copy-public', + name: 'Copy Public', + type: 'include_assets', + config: { + inclusions: [{type: 'pattern', baseDir: 'public', destination: 'static'}], + }, + } + + // When + await executeIncludeAssetsStep(step, mockContext) + + // Then + await expect(fileExists(joinPath(outputDir, 'static/logo.png'))).resolves.toBe(true) + }) }) test('returns zero and warns when no files match', async () => { - // Given - vi.mocked(fs.glob).mockResolvedValue([]) - vi.mocked(fs.mkdir).mockResolvedValue() - - const step: LifecycleStep = { - id: 'copy-public', - name: 'Copy Public', - type: 'include_assets', - config: { - inclusions: [{type: 'pattern', baseDir: 'public'}], - }, - } - - // When - const result = await executeIncludeAssetsStep(step, mockContext) - - // Then - expect(result.filesCopied).toBe(0) + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir} = await setupTestEnvironment(tmpDir) + const publicDir = joinPath(extensionDir, 'public') + await mkdir(publicDir) + + const step: LifecycleStep = { + id: 'copy-public', + name: 'Copy Public', + type: 'include_assets', + config: { + inclusions: [{type: 'pattern', baseDir: 'public'}], + }, + } + + // When + const result = await executeIncludeAssetsStep(step, mockContext) + + // Then + expect(result.filesCopied).toBe(0) + }) }) }) describe('mixed inclusions', () => { test('executes all entry types in parallel and aggregates filesCopied count', async () => { - // Given - const contextWithConfig = { - ...mockContext, - extension: { - ...mockExtension, - configuration: {theme_root: 'theme'}, - } as unknown as ExtensionInstance, - } - - vi.mocked(fs.fileExists).mockResolvedValue(true) - // Directories have no file extension; files do - vi.mocked(fs.isDirectory).mockImplementation(async (path) => !/\.\w+$/.test(String(path))) - vi.mocked(fs.copyDirectoryContents).mockResolvedValue() - vi.mocked(fs.copyFile).mockResolvedValue() - vi.mocked(fs.mkdir).mockResolvedValue() - // configKey entries run sequentially first, then pattern/static in parallel. - // glob: first call for configKey dir listing, second for pattern source files. - vi.mocked(fs.glob) - .mockResolvedValueOnce(['index.html', 'style.css']) - .mockResolvedValueOnce(['/test/extension/assets/logo.png', '/test/extension/assets/icon.svg']) - - const step: LifecycleStep = { - id: 'include-all', - name: 'Include All', - type: 'include_assets', - config: { - inclusions: [ - {type: 'pattern', baseDir: 'assets', include: ['**/*.png', '**/*.svg']}, - {type: 'configKey', key: 'theme_root'}, - {type: 'static', source: 'src/manifest.json', destination: 'manifest.json'}, - ], - }, - } - - // When - const result = await executeIncludeAssetsStep(step, contextWithConfig) - - // Then - // 5 = 2 pattern + 2 configKey dir contents + 1 explicit file (manifest.json is a file → copyFile → 1) - expect(result.filesCopied).toBe(5) - expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/src/manifest.json', '/test/output/manifest.json') - expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/theme', '/test/output') + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + const themeDir = joinPath(extensionDir, 'theme') + await mkdir(themeDir) + await writeFile(joinPath(themeDir, 'index.html'), 'html') + await writeFile(joinPath(themeDir, 'style.css'), 'css') + + const assetsDir = joinPath(extensionDir, 'assets') + await mkdir(assetsDir) + await writeFile(joinPath(assetsDir, 'logo.png'), 'png') + await writeFile(joinPath(assetsDir, 'icon.svg'), 'svg') + + const srcDir = joinPath(extensionDir, 'src') + await mkdir(srcDir) + await writeFile(joinPath(srcDir, 'manifest.json'), 'json') + + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {theme_root: 'theme'}, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'include-all', + name: 'Include All', + type: 'include_assets', + config: { + inclusions: [ + {type: 'pattern', baseDir: 'assets', include: ['**/*.png', '**/*.svg']}, + {type: 'configKey', key: 'theme_root'}, + {type: 'static', source: 'src/manifest.json', destination: 'manifest.json'}, + ], + }, + } + + // When + const result = await executeIncludeAssetsStep(step, contextWithConfig) + + // Then + // 5 = 2 pattern + 2 configKey dir contents + 1 explicit file + expect(result.filesCopied).toBe(5) + await expect(fileExists(joinPath(outputDir, 'manifest.json'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'index.html'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'style.css'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'logo.png'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'icon.svg'))).resolves.toBe(true) + }) }) }) describe('manifest generation', () => { - beforeEach(() => { - vi.mocked(fs.writeFile).mockResolvedValue() - vi.mocked(fs.mkdir).mockResolvedValue() - // Source files exist. Individual tests can override for specific scenarios. - vi.mocked(fs.fileExists).mockImplementation( - async (path) => typeof path === 'string' && path.startsWith('/test/extension'), - ) - vi.mocked(fs.copyFile).mockResolvedValue() - vi.mocked(fs.copyDirectoryContents).mockResolvedValue() - vi.mocked(fs.glob).mockResolvedValue([]) - }) - test('writes manifest.json with a single configKey inclusion using anchor and groupBy', async () => { - // Given - const contextWithConfig = { - ...mockContext, - extension: { - ...mockExtension, - configuration: { - extensions: [ + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + await writeFile(joinPath(extensionDir, 'tools.json'), 'tools') + + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + extensions: [ + { + targeting: [{target: 'admin.app.intent.link', tools: './tools.json', url: '/editor'}], + }, + ], + }, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generatesAssetsManifest: true, + inclusions: [ { - targeting: [{target: 'admin.app.intent.link', tools: './tools.json', url: '/editor'}], + type: 'configKey', + key: 'extensions[].targeting[].tools', + anchor: 'extensions[].targeting[]', + groupBy: 'target', }, ], }, - } as unknown as ExtensionInstance, - } - - const step: LifecycleStep = { - id: 'gen-manifest', - name: 'Generate Manifest', - type: 'include_assets', - config: { - generatesAssetsManifest: true, - inclusions: [ - { - type: 'configKey', - key: 'extensions[].targeting[].tools', - anchor: 'extensions[].targeting[]', - groupBy: 'target', - }, - ], - }, - } - - // When - await executeIncludeAssetsStep(step, contextWithConfig) - - // Then - expect(fs.writeFile).toHaveBeenCalledOnce() - const writeFileCall = vi.mocked(fs.writeFile).mock.calls[0]! - expect(writeFileCall[0]).toBe('/test/output/manifest.json') - const manifestContent = JSON.parse(writeFileCall[1] as string) - expect(manifestContent).toEqual({ - 'admin.app.intent.link': { - tools: 'tools.json', - }, + } + + // When + await executeIncludeAssetsStep(step, contextWithConfig) + + // Then + await expect(fileExists(joinPath(outputDir, 'manifest.json'))).resolves.toBe(true) + const manifestContent = JSON.parse(await readFile(joinPath(outputDir, 'manifest.json'))) + expect(manifestContent).toEqual({ + 'admin.app.intent.link': { + tools: 'tools.json', + }, + }) }) }) test('merges multiple inclusions per target when they share the same anchor and groupBy', async () => { - // Given - const contextWithConfig = { - ...mockContext, - extension: { - ...mockExtension, - configuration: { - extensions: [ + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + await writeFile(joinPath(extensionDir, 'tools.json'), 'tools') + await writeFile(joinPath(extensionDir, 'instructions.md'), 'instructions') + await writeFile(joinPath(extensionDir, 'email-schema.json'), 'schema') + + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + extensions: [ + { + targeting: [ + { + target: 'admin.app.intent.link', + tools: './tools.json', + instructions: './instructions.md', + url: '/editor', + intents: [{type: 'application/email', action: 'open', schema: './email-schema.json'}], + }, + ], + }, + ], + }, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generatesAssetsManifest: true, + inclusions: [ + { + type: 'configKey', + key: 'extensions[].targeting[].tools', + anchor: 'extensions[].targeting[]', + groupBy: 'target', + }, { - targeting: [ - { - target: 'admin.app.intent.link', - tools: './tools.json', - instructions: './instructions.md', - url: '/editor', - intents: [{type: 'application/email', action: 'open', schema: './email-schema.json'}], - }, - ], + type: 'configKey', + key: 'extensions[].targeting[].instructions', + anchor: 'extensions[].targeting[]', + groupBy: 'target', + }, + { + type: 'configKey', + key: 'extensions[].targeting[].intents[].schema', + anchor: 'extensions[].targeting[]', + groupBy: 'target', }, ], }, - } as unknown as ExtensionInstance, - } - - const step: LifecycleStep = { - id: 'gen-manifest', - name: 'Generate Manifest', - type: 'include_assets', - config: { - generatesAssetsManifest: true, - inclusions: [ - { - type: 'configKey', - key: 'extensions[].targeting[].tools', - anchor: 'extensions[].targeting[]', - groupBy: 'target', - }, - { - type: 'configKey', - key: 'extensions[].targeting[].instructions', - anchor: 'extensions[].targeting[]', - groupBy: 'target', - }, - { - type: 'configKey', - key: 'extensions[].targeting[].intents[].schema', - anchor: 'extensions[].targeting[]', - groupBy: 'target', - }, - ], - }, - } - - // When - await executeIncludeAssetsStep(step, contextWithConfig) - - // Then — url is NOT in the manifest because no inclusion references it - expect(fs.writeFile).toHaveBeenCalledOnce() - const writeFileCall = vi.mocked(fs.writeFile).mock.calls[0]! - const manifestContent = JSON.parse(writeFileCall[1] as string) - expect(manifestContent).toEqual({ - 'admin.app.intent.link': { - tools: 'tools.json', - instructions: 'instructions.md', - intents: [{schema: 'email-schema.json'}], - }, + } + + // When + await executeIncludeAssetsStep(step, contextWithConfig) + + // Then + const manifestContent = JSON.parse(await readFile(joinPath(outputDir, 'manifest.json'))) + expect(manifestContent).toEqual({ + 'admin.app.intent.link': { + tools: 'tools.json', + instructions: 'instructions.md', + intents: [{schema: 'email-schema.json'}], + }, + }) }) }) test('produces one manifest key per targeting entry when multiple entries exist', async () => { - // Given - const contextWithConfig = { - ...mockContext, - extension: { - ...mockExtension, - configuration: { - extensions: [ + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + await writeFile(joinPath(extensionDir, 'tools-a.js'), 'a') + await writeFile(joinPath(extensionDir, 'schema1.json'), '1') + await writeFile(joinPath(extensionDir, 'tools-b.js'), 'b') + await writeFile(joinPath(extensionDir, 'schema2.json'), '2') + + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + extensions: [ + { + targeting: [ + {target: 'admin.intent.link', tools: './tools-a.js', intents: [{schema: './schema1.json'}]}, + {target: 'admin.other.target', tools: './tools-b.js', intents: [{schema: './schema2.json'}]}, + ], + }, + ], + }, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generatesAssetsManifest: true, + inclusions: [ { - targeting: [ - {target: 'admin.intent.link', tools: './tools-a.js', intents: [{schema: './schema1.json'}]}, - {target: 'admin.other.target', tools: './tools-b.js', intents: [{schema: './schema2.json'}]}, - ], + type: 'configKey', + key: 'extensions[].targeting[].tools', + anchor: 'extensions[].targeting[]', + groupBy: 'target', + }, + { + type: 'configKey', + key: 'extensions[].targeting[].intents[].schema', + anchor: 'extensions[].targeting[]', + groupBy: 'target', }, ], }, - } as unknown as ExtensionInstance, - } - - const step: LifecycleStep = { - id: 'gen-manifest', - name: 'Generate Manifest', - type: 'include_assets', - config: { - generatesAssetsManifest: true, - inclusions: [ - { - type: 'configKey', - key: 'extensions[].targeting[].tools', - anchor: 'extensions[].targeting[]', - groupBy: 'target', - }, - { - type: 'configKey', - key: 'extensions[].targeting[].intents[].schema', - anchor: 'extensions[].targeting[]', - groupBy: 'target', - }, - ], - }, - } - - // When - await executeIncludeAssetsStep(step, contextWithConfig) - - // Then — two top-level keys, one per targeting entry - expect(fs.writeFile).toHaveBeenCalledOnce() - const writeFileCall = vi.mocked(fs.writeFile).mock.calls[0]! - const manifestContent = JSON.parse(writeFileCall[1] as string) - expect(manifestContent).toEqual({ - 'admin.intent.link': { - tools: 'tools-a.js', - intents: [{schema: 'schema1.json'}], - }, - 'admin.other.target': { - tools: 'tools-b.js', - intents: [{schema: 'schema2.json'}], - }, + } + + // When + await executeIncludeAssetsStep(step, contextWithConfig) + + // Then + const manifestContent = JSON.parse(await readFile(joinPath(outputDir, 'manifest.json'))) + expect(manifestContent).toEqual({ + 'admin.intent.link': { + tools: 'tools-a.js', + intents: [{schema: 'schema1.json'}], + }, + 'admin.other.target': { + tools: 'tools-b.js', + intents: [{schema: 'schema2.json'}], + }, + }) }) }) test('does NOT write manifest.json when generatesAssetsManifest is false (default)', async () => { - // Given - const contextWithConfig = { - ...mockContext, - extension: { - ...mockExtension, - configuration: { - extensions: [ + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + await writeFile(joinPath(extensionDir, 'tools.json'), 'tools') + + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + extensions: [ + { + targeting: [{target: 'admin.intent.link', tools: './tools.json'}], + }, + ], + }, + } as unknown as ExtensionInstance, + } + + // No generatesAssetsManifest field — defaults to false + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + inclusions: [ { - targeting: [{target: 'admin.intent.link', tools: './tools.json'}], + type: 'configKey', + key: 'extensions[].targeting[].tools', + anchor: 'extensions[].targeting[]', + groupBy: 'target', }, ], }, - } as unknown as ExtensionInstance, - } - - // No generatesAssetsManifest field — defaults to false - const step: LifecycleStep = { - id: 'gen-manifest', - name: 'Generate Manifest', - type: 'include_assets', - config: { - inclusions: [ - { - type: 'configKey', - key: 'extensions[].targeting[].tools', - anchor: 'extensions[].targeting[]', - groupBy: 'target', - }, - ], - }, - } + } - // When - await executeIncludeAssetsStep(step, contextWithConfig) + // When + await executeIncludeAssetsStep(step, contextWithConfig) - // Then - expect(fs.writeFile).not.toHaveBeenCalled() + // Then + await expect(fileExists(joinPath(outputDir, 'manifest.json'))).resolves.toBe(false) + }) }) test('writes manifest.json with files array when generatesAssetsManifest is true and only pattern inclusions exist', async () => { - // Given — pattern entries contribute output paths to the manifest "files" array. - // sourceDir must exist for copyByPattern's pre-glob fileExists check to pass; - // everything else can read false (the parent beforeEach default). - vi.mocked(fs.glob).mockResolvedValue(['/test/extension/public/logo.png']) - vi.mocked(fs.copyFile).mockResolvedValue() - vi.mocked(fs.mkdir).mockResolvedValue() - vi.mocked(fs.fileExists).mockImplementation(async (path) => String(path) === '/test/extension/public') - vi.mocked(fs.writeFile).mockResolvedValue() - - const step: LifecycleStep = { - id: 'gen-manifest', - name: 'Generate Manifest', - type: 'include_assets', - config: { - generatesAssetsManifest: true, - inclusions: [{type: 'pattern', baseDir: 'public', include: ['**/*']}], - }, - } - - // When - await executeIncludeAssetsStep(step, mockContext) - - // Then — pattern entry contributes its output path to the manifest - expect(fs.writeFile).toHaveBeenCalledOnce() - const manifestContent = JSON.parse(vi.mocked(fs.writeFile).mock.calls[0]![1] as string) - expect(manifestContent).toEqual({files: ['logo.png']}) + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + const publicDir = joinPath(extensionDir, 'public') + await mkdir(publicDir) + await writeFile(joinPath(publicDir, 'logo.png'), 'png') + + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generatesAssetsManifest: true, + inclusions: [{type: 'pattern', baseDir: 'public', include: ['**/*']}], + }, + } + + // When + await executeIncludeAssetsStep(step, mockContext) + + // Then + const manifestContent = JSON.parse(await readFile(joinPath(outputDir, 'manifest.json'))) + expect(manifestContent).toEqual({files: ['logo.png']}) + }) }) test('writes manifest.json with files array from static entry when generatesAssetsManifest is true', async () => { - // Given — static file entry contributes its output path to the manifest "files" array - vi.mocked(fs.fileExists).mockResolvedValue(true) - vi.mocked(fs.isDirectory).mockResolvedValue(false) - vi.mocked(fs.mkdir).mockResolvedValue() - vi.mocked(fs.copyFile).mockResolvedValue() - vi.mocked(fs.writeFile).mockResolvedValue() - - // fileExists returns false for the manifest.json output path check - vi.mocked(fs.fileExists).mockImplementation(async (path) => String(path) !== '/test/output/manifest.json') - - const step: LifecycleStep = { - id: 'gen-manifest', - name: 'Generate Manifest', - type: 'include_assets', - config: { - generatesAssetsManifest: true, - inclusions: [{type: 'static', source: 'src/schema.json'}], - }, - } - - // When - await executeIncludeAssetsStep(step, mockContext) - - // Then — static entry contributes its output path to the manifest - expect(fs.writeFile).toHaveBeenCalledOnce() - const manifestContent = JSON.parse(vi.mocked(fs.writeFile).mock.calls[0]![1] as string) - expect(manifestContent).toEqual({files: ['schema.json']}) + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + const srcDir = joinPath(extensionDir, 'src') + await mkdir(srcDir) + await writeFile(joinPath(srcDir, 'schema.json'), 'schema') + + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generatesAssetsManifest: true, + inclusions: [{type: 'static', source: 'src/schema.json'}], + }, + } + + // When + await executeIncludeAssetsStep(step, mockContext) + + // Then + const manifestContent = JSON.parse(await readFile(joinPath(outputDir, 'manifest.json'))) + expect(manifestContent).toEqual({files: ['schema.json']}) + }) }) test('writes root-level manifest entry from non-anchored configKey inclusion', async () => { - // Given — configKey without anchor/groupBy contributes at manifest root - const contextWithConfig = { - ...mockContext, - extension: { - ...mockExtension, - configuration: {targeting: {tools: './tools.json', instructions: './instructions.md'}}, - } as unknown as ExtensionInstance, - } - - const step: LifecycleStep = { - id: 'gen-manifest', - name: 'Generate Manifest', - type: 'include_assets', - config: { - generatesAssetsManifest: true, - inclusions: [ - {type: 'configKey', key: 'targeting.tools'}, - {type: 'configKey', key: 'targeting.instructions'}, - ], - }, - } - - // When - await executeIncludeAssetsStep(step, contextWithConfig) - - // Then — root-level keys use last path segment; values are output-relative paths - expect(fs.writeFile).toHaveBeenCalledOnce() - const manifestContent = JSON.parse(vi.mocked(fs.writeFile).mock.calls[0]![1] as string) - expect(manifestContent).toEqual({ - tools: 'tools.json', - instructions: 'instructions.md', + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + await writeFile(joinPath(extensionDir, 'tools.json'), 'tools') + await writeFile(joinPath(extensionDir, 'instructions.md'), 'instructions') + + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {targeting: {tools: './tools.json', instructions: './instructions.md'}}, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generatesAssetsManifest: true, + inclusions: [ + {type: 'configKey', key: 'targeting.tools'}, + {type: 'configKey', key: 'targeting.instructions'}, + ], + }, + } + + // When + await executeIncludeAssetsStep(step, contextWithConfig) + + // Then + const manifestContent = JSON.parse(await readFile(joinPath(outputDir, 'manifest.json'))) + expect(manifestContent).toEqual({ + tools: 'tools.json', + instructions: 'instructions.md', + }) }) }) test('maps a directory configKey to a file list in the manifest', async () => { - // Directory sources produce a string[] of output-relative file paths rather - // than an opaque directory marker like "." or "". - const contextWithConfig = { - ...mockContext, - extension: { - ...mockExtension, - configuration: {admin: {static_root: 'dist'}}, - } as unknown as ExtensionInstance, - } - - vi.mocked(fs.fileExists).mockImplementation(async (pathArg) => { - // The source 'dist' directory must exist so the copy runs; manifest.json must not - return String(pathArg) === '/test/extension/dist' + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + const distDir = joinPath(extensionDir, 'dist') + await mkdir(distDir) + await writeFile(joinPath(distDir, 'index.html'), 'html') + await writeFile(joinPath(distDir, 'style.css'), 'css') + + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {admin: {static_root: 'dist'}}, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'copy-static', + name: 'Copy Static', + type: 'include_assets', + config: { + generatesAssetsManifest: true, + inclusions: [{type: 'configKey', key: 'admin.static_root'}], + }, + } + + // When + await executeIncludeAssetsStep(step, contextWithConfig) + + // Then + const manifestContent = JSON.parse(await readFile(joinPath(outputDir, 'manifest.json'))) + expect(manifestContent).toEqual({static_root: ['index.html', 'style.css']}) }) - vi.mocked(fs.isDirectory).mockResolvedValue(true) - vi.mocked(fs.copyDirectoryContents).mockResolvedValue() - vi.mocked(fs.glob).mockResolvedValue(['index.html', 'style.css']) - - const step: LifecycleStep = { - id: 'copy-static', - name: 'Copy Static', - type: 'include_assets', - config: { - generatesAssetsManifest: true, - // no destination → contents merged into output root - inclusions: [{type: 'configKey', key: 'admin.static_root'}], - }, - } - - // When - await executeIncludeAssetsStep(step, contextWithConfig) - - // Then — directory produces a file list, not an opaque directory marker - expect(fs.writeFile).toHaveBeenCalledOnce() - const manifestContent = JSON.parse(vi.mocked(fs.writeFile).mock.calls[0]![1] as string) - expect(manifestContent).toEqual({static_root: ['index.html', 'style.css']}) }) test('throws a validation error when only anchor is set without groupBy', async () => { - // Given — inclusion has anchor but no groupBy — schema now rejects this at parse time - const step: LifecycleStep = { - id: 'gen-manifest', - name: 'Generate Manifest', - type: 'include_assets', - config: { - generatesAssetsManifest: true, - inclusions: [{type: 'configKey', key: 'targeting.tools', anchor: 'targeting'}], - }, - } - - // When / Then — schema refinement rejects anchor without groupBy - await expect(executeIncludeAssetsStep(step, mockContext)).rejects.toThrow( - '`anchor` and `groupBy` must both be set or both be omitted', - ) + await inTemporaryDirectory(async (tmpDir) => { + // Given + await setupTestEnvironment(tmpDir) + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generatesAssetsManifest: true, + inclusions: [{type: 'configKey', key: 'targeting.tools', anchor: 'targeting'}] as any, + }, + } + + // When / Then — schema refinement rejects anchor without groupBy + await expect(executeIncludeAssetsStep(step, mockContext)).rejects.toThrow( + '`anchor` and `groupBy` must both be set or both be omitted', + ) + }) }) test('overwrites manifest.json when it already exists in the output directory', async () => { - // Given — a prior inclusion already copied a manifest.json to the output dir - const contextWithConfig = { - ...mockContext, - extension: { - ...mockExtension, - configuration: { - extensions: [{targeting: [{target: 'admin.intent.link', tools: './tools.json'}]}], + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + await writeFile(joinPath(extensionDir, 'tools.json'), 'tools') + await writeFile(joinPath(outputDir, 'manifest.json'), 'old manifest') + + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + extensions: [{targeting: [{target: 'admin.intent.link', tools: './tools.json'}]}], + }, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generatesAssetsManifest: true, + inclusions: [ + { + type: 'configKey', + key: 'extensions[].targeting[].tools', + anchor: 'extensions[].targeting[]', + groupBy: 'target', + }, + ], }, - } as unknown as ExtensionInstance, - } + } - // Source files exist; output manifest.json already exists from a prior step - vi.mocked(fs.fileExists).mockImplementation(async (path) => { - const pathStr = String(path) - return pathStr === '/test/output/manifest.json' || pathStr.startsWith('/test/extension/') - }) - vi.mocked(fs.readFile).mockResolvedValue(Buffer.from('{ "admin.intent.link": { "tools": "tools.json" } }')) - vi.mocked(fs.glob).mockResolvedValue([]) - - const step: LifecycleStep = { - id: 'gen-manifest', - name: 'Generate Manifest', - type: 'include_assets', - config: { - generatesAssetsManifest: true, - inclusions: [ - { - type: 'configKey', - key: 'extensions[].targeting[].tools', - anchor: 'extensions[].targeting[]', - groupBy: 'target', - }, - ], - }, - } + // When + await executeIncludeAssetsStep(step, contextWithConfig) - // When / Then — overwrites existing manifest.json - await expect(executeIncludeAssetsStep(step, contextWithConfig)).resolves.not.toThrow() - expect(fs.writeFile).toHaveBeenCalledWith('/test/output/manifest.json', expect.any(String)) + // Then + const manifestContent = JSON.parse(await readFile(joinPath(outputDir, 'manifest.json'))) + expect(manifestContent).toEqual({ + 'admin.intent.link': { + tools: 'tools.json', + }, + }) + }) }) test('writes an empty manifest when anchor resolves to a non-array value', async () => { - // Given — "extensions" is a plain string, not an array; the [] flatten marker - // returns undefined, so the anchor group is skipped and the manifest is empty - const contextWithConfig = { - ...mockContext, - extension: { - ...mockExtension, - configuration: { - extensions: 'not-an-array', - }, - } as unknown as ExtensionInstance, - } - - const step: LifecycleStep = { - id: 'gen-manifest', - name: 'Generate Manifest', - type: 'include_assets', - config: { - generatesAssetsManifest: true, - inclusions: [ - { - type: 'configKey', - key: 'extensions[].targeting[].tools', - anchor: 'extensions[].targeting[]', - groupBy: 'target', + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {outputDir} = await setupTestEnvironment(tmpDir) + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + extensions: 'not-an-array', }, - ], - }, - } + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generatesAssetsManifest: true, + inclusions: [ + { + type: 'configKey', + key: 'extensions[].targeting[].tools', + anchor: 'extensions[].targeting[]', + groupBy: 'target', + }, + ], + }, + } - // When - await executeIncludeAssetsStep(step, contextWithConfig) + // When + await executeIncludeAssetsStep(step, contextWithConfig) - // Then — no entries produced; manifest.json is NOT written, warning is logged - expect(fs.writeFile).not.toHaveBeenCalled() - expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining('no manifest entries produced')) + // Then + await expect(fileExists(joinPath(outputDir, 'manifest.json'))).resolves.toBe(false) + expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining('no manifest entries produced')) + }) }) test('skips items whose groupBy field is not a string', async () => { - // Given — one entry has a numeric target, the other has a valid string target - const contextWithConfig = { - ...mockContext, - extension: { - ...mockExtension, - configuration: { - extensions: [ + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + await writeFile(joinPath(extensionDir, 'tools-good.js'), 'good') + await writeFile(joinPath(extensionDir, 'tools-bad.js'), 'bad') + + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + extensions: [ + { + targeting: [ + {target: 42, tools: './tools-bad.js'}, + {target: 'admin.link', tools: './tools-good.js'}, + ], + }, + ], + }, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generatesAssetsManifest: true, + inclusions: [ { - targeting: [ - {target: 42, tools: './tools-bad.js'}, - {target: 'admin.link', tools: './tools-good.js'}, - ], + type: 'configKey', + key: 'extensions[].targeting[].tools', + anchor: 'extensions[].targeting[]', + groupBy: 'target', }, ], }, - } as unknown as ExtensionInstance, - } - - const step: LifecycleStep = { - id: 'gen-manifest', - name: 'Generate Manifest', - type: 'include_assets', - config: { - generatesAssetsManifest: true, - inclusions: [ - { - type: 'configKey', - key: 'extensions[].targeting[].tools', - anchor: 'extensions[].targeting[]', - groupBy: 'target', - }, - ], - }, - } - - // When - await executeIncludeAssetsStep(step, contextWithConfig) - - // Then — only the string-keyed entry appears - expect(fs.writeFile).toHaveBeenCalledOnce() - const writeFileCall = vi.mocked(fs.writeFile).mock.calls[0]! - const manifestContent = JSON.parse(writeFileCall[1] as string) - expect(manifestContent).toEqual({ - 'admin.link': {tools: 'tools-good.js'}, + } + + // When + await executeIncludeAssetsStep(step, contextWithConfig) + + // Then + const manifestContent = JSON.parse(await readFile(joinPath(outputDir, 'manifest.json'))) + expect(manifestContent).toEqual({ + 'admin.link': {tools: 'tools-good.js'}, + }) }) - expect(manifestContent).not.toHaveProperty('42') }) test('writes manifest.json to outputDir derived from extension.outputPath', async () => { - // Given — outputPath is a file, so outputDir is its dirname (/test/output) - const contextWithConfig = { - ...mockContext, - extension: { - ...mockExtension, - outputPath: '/test/output/extension.js', - configuration: { - extensions: [{targeting: [{target: 'admin.intent.link', tools: './tools.json'}]}], - }, - } as unknown as ExtensionInstance, - } - - const step: LifecycleStep = { - id: 'gen-manifest', - name: 'Generate Manifest', - type: 'include_assets', - config: { - generatesAssetsManifest: true, - inclusions: [ - { - type: 'configKey', - key: 'extensions[].targeting[].tools', - anchor: 'extensions[].targeting[]', - groupBy: 'target', + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + await writeFile(joinPath(extensionDir, 'tools.json'), 'tools') + + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + outputPath: joinPath(outputDir, 'extension.js'), + configuration: { + extensions: [{targeting: [{target: 'admin.intent.link', tools: './tools.json'}]}], }, - ], - }, - } + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generatesAssetsManifest: true, + inclusions: [ + { + type: 'configKey', + key: 'extensions[].targeting[].tools', + anchor: 'extensions[].targeting[]', + groupBy: 'target', + }, + ], + }, + } - // When - await executeIncludeAssetsStep(step, contextWithConfig) + // When + await executeIncludeAssetsStep(step, contextWithConfig) - // Then — manifest is placed under /test/output, which is dirname of extension.js - expect(fs.writeFile).toHaveBeenCalledOnce() - const writeFileCall = vi.mocked(fs.writeFile).mock.calls[0]! - expect(writeFileCall[0]).toBe('/test/output/manifest.json') + // Then + await expect(fileExists(joinPath(outputDir, 'manifest.json'))).resolves.toBe(true) + }) }) test('still copies files AND writes manifest when generatesAssetsManifest is true', async () => { - // Given - const contextWithConfig = { - ...mockContext, - extension: { - ...mockExtension, - configuration: { - extensions: [{targeting: [{target: 'admin.intent.link', tools: './tools.json'}]}], - }, - } as unknown as ExtensionInstance, - } - - vi.mocked(fs.glob).mockResolvedValue([]) - - const step: LifecycleStep = { - id: 'gen-manifest', - name: 'Generate Manifest', - type: 'include_assets', - config: { - generatesAssetsManifest: true, - inclusions: [ - { - type: 'configKey', - key: 'extensions[].targeting[].tools', - anchor: 'extensions[].targeting[]', - groupBy: 'target', + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + await writeFile(joinPath(extensionDir, 'tools.json'), 'tools') + + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + extensions: [{targeting: [{target: 'admin.intent.link', tools: './tools.json'}]}], }, - ], - }, - } - - // When - await executeIncludeAssetsStep(step, contextWithConfig) - - // Then — file copying happened AND manifest was written - // joinPath normalises './tools.json' → 'tools.json', so the resolved source path has no leading './' - expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/tools.json', '/test/output/tools.json') - expect(fs.writeFile).toHaveBeenCalledOnce() - const writeFileCall = vi.mocked(fs.writeFile).mock.calls[0]! - const manifestContent = JSON.parse(writeFileCall[1] as string) - expect(manifestContent).toEqual({ - 'admin.intent.link': {tools: 'tools.json'}, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generatesAssetsManifest: true, + inclusions: [ + { + type: 'configKey', + key: 'extensions[].targeting[].tools', + anchor: 'extensions[].targeting[]', + groupBy: 'target', + }, + ], + }, + } + + // When + await executeIncludeAssetsStep(step, contextWithConfig) + + // Then + await expect(fileExists(joinPath(outputDir, 'tools.json'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'manifest.json'))).resolves.toBe(true) }) }) test('resolves bare filename in manifest even without ./ prefix', async () => { - // Given — config value is a bare filename with no ./ prefix; pathMap.has() must catch it - const contextWithConfig = { - ...mockContext, - extension: { - ...mockExtension, - configuration: { - extensions: [{targeting: [{target: 'admin.intent.link', tools: 'tools.json'}]}], - }, - } as unknown as ExtensionInstance, - } - - vi.mocked(fs.glob).mockResolvedValue([]) - - const step: LifecycleStep = { - id: 'gen-manifest', - name: 'Generate Manifest', - type: 'include_assets', - config: { - generatesAssetsManifest: true, - inclusions: [ - { - type: 'configKey', - key: 'extensions[].targeting[].tools', - anchor: 'extensions[].targeting[]', - groupBy: 'target', + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir, outputDir} = await setupTestEnvironment(tmpDir) + await writeFile(joinPath(extensionDir, 'tools.json'), 'tools') + + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + extensions: [{targeting: [{target: 'admin.intent.link', tools: 'tools.json'}]}], }, - ], - }, - } - - // When - await executeIncludeAssetsStep(step, contextWithConfig) - - // Then — 'tools.json' (no ./ prefix) must be resolved to its output-relative path in the manifest - expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/tools.json', '/test/output/tools.json') - const writeFileCall = vi.mocked(fs.writeFile).mock.calls[0]! - const manifestContent = JSON.parse(writeFileCall[1] as string) - expect(manifestContent).toEqual({ - 'admin.intent.link': {tools: 'tools.json'}, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generatesAssetsManifest: true, + inclusions: [ + { + type: 'configKey', + key: 'extensions[].targeting[].tools', + anchor: 'extensions[].targeting[]', + groupBy: 'target', + }, + ], + }, + } + + // When + await executeIncludeAssetsStep(step, contextWithConfig) + + // Then + await expect(fileExists(joinPath(outputDir, 'tools.json'))).resolves.toBe(true) + const manifestContent = JSON.parse(await readFile(joinPath(outputDir, 'manifest.json'))) + expect(manifestContent).toEqual({ + 'admin.intent.link': {tools: 'tools.json'}, + }) }) }) test('includes the full item when anchor equals key (relPath is empty string)', async () => { - // Given — anchor === key, so stripAnchorPrefix returns "" and buildRelativeEntry returns the whole item - const contextWithConfig = { - ...mockContext, - extension: { - ...mockExtension, - configuration: { - extensions: [ + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {outputDir} = await setupTestEnvironment(tmpDir) + + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + extensions: [ + { + targeting: [{target: 'admin.intent.link', tools: './tools.json', url: '/editor'}], + }, + ], + }, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generatesAssetsManifest: true, + inclusions: [ { - targeting: [{target: 'admin.intent.link', tools: './tools.json', url: '/editor'}], + type: 'configKey', + key: 'extensions[].targeting[]', + anchor: 'extensions[].targeting[]', + groupBy: 'target', }, ], }, - } as unknown as ExtensionInstance, - } - - vi.mocked(fs.fileExists).mockResolvedValue(false) - - const step: LifecycleStep = { - id: 'gen-manifest', - name: 'Generate Manifest', - type: 'include_assets', - config: { - generatesAssetsManifest: true, - inclusions: [ - { - type: 'configKey', - // anchor === key → the whole targeting item becomes the manifest value - key: 'extensions[].targeting[]', - anchor: 'extensions[].targeting[]', - groupBy: 'target', - }, - ], - }, - } - - // When - await executeIncludeAssetsStep(step, contextWithConfig) - - // Then — manifest value is the full targeting object (including url). - // tools: './tools.json' was never copied (configKey resolved to an object, not a string), - // so the path is left as-is and a warning is logged. - expect(fs.writeFile).toHaveBeenCalledOnce() - const writeFileCall = vi.mocked(fs.writeFile).mock.calls[0]! - const manifestContent = JSON.parse(writeFileCall[1] as string) - expect(manifestContent).toEqual({ - 'admin.intent.link': { - target: 'admin.intent.link', - tools: './tools.json', - url: '/editor', - }, + } + + // When + await executeIncludeAssetsStep(step, contextWithConfig) + + // Then + const manifestContent = JSON.parse(await readFile(joinPath(outputDir, 'manifest.json'))) + expect(manifestContent).toEqual({ + 'admin.intent.link': { + target: 'admin.intent.link', + tools: './tools.json', + url: '/editor', + }, + }) + expect(mockStdout.write).toHaveBeenCalledWith( + expect.stringContaining("manifest entry 'admin.intent.link' contains unresolved paths"), + ) }) - expect(mockStdout.write).toHaveBeenCalledWith( - expect.stringContaining("manifest entry 'admin.intent.link' contains unresolved paths"), - ) }) test('throws when a referenced source file does not exist on disk', async () => { - const contextWithConfig = { - ...mockContext, - extension: { - ...mockExtension, - configuration: { - extensions: [{targeting: [{target: 'admin.intent.link', tools: './tools.json'}]}], - }, - } as unknown as ExtensionInstance, - } - - vi.mocked(fs.fileExists).mockResolvedValue(false) - - const step: LifecycleStep = { - id: 'gen-manifest', - name: 'Generate Manifest', - type: 'include_assets', - config: { - generatesAssetsManifest: true, - inclusions: [ - { - type: 'configKey', - key: 'extensions[].targeting[].tools', - anchor: 'extensions[].targeting[]', - groupBy: 'target', + await inTemporaryDirectory(async (tmpDir) => { + // Given + const {extensionDir} = await setupTestEnvironment(tmpDir) + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + extensions: [{targeting: [{target: 'admin.intent.link', tools: './tools.json'}]}], }, - ], - }, - } + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generatesAssetsManifest: true, + inclusions: [ + { + type: 'configKey', + key: 'extensions[].targeting[].tools', + anchor: 'extensions[].targeting[]', + groupBy: 'target', + }, + ], + }, + } - await expect(executeIncludeAssetsStep(step, contextWithConfig)).rejects.toThrow( - `Couldn't find /test/extension/tools.json\n Please check the path './tools.json' in your configuration`, - ) + await expect(executeIncludeAssetsStep(step, contextWithConfig)).rejects.toThrow( + `Couldn't find ${joinPath(extensionDir, 'tools.json')}\n Please check the path './tools.json' in your configuration`, + ) + }) }) }) })