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..615a9a952ec 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,280 +14,456 @@ describe('executeIncludeAssetsStep', () => { beforeEach(() => { mockStdout = {write: vi.fn()} mockStderr = {write: vi.fn()} - mockExtension = { - directory: '/test/extension', - outputPath: '/test/output/extension.js', - } as ExtensionInstance - - mockContext = { - extension: mockExtension, - options: { - stdout: mockStdout, - stderr: mockStderr, - app: {directory: '/test'} as any, - environment: 'production', - }, - stepResults: new Map(), - } }) 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 = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + const distDir = joinPath(extensionDir, 'dist') + await mkdir(distDir) + await mkdir(outputDir) + await writeFile(joinPath(distDir, 'index.html'), '') + await writeFile(joinPath(distDir, 'logo.png'), 'png-content') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), + } as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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/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 + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), + } as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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 = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(joinPath(extensionDir, 'src')) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'src/icon.png'), 'icon-content') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), + } as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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 + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), + } as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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 = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(joinPath(extensionDir, 'dist')) + await mkdir(joinPath(extensionDir, 'src')) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'dist/index.html'), 'html') + await writeFile(joinPath(extensionDir, 'src/icon.png'), 'icon') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), + } as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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 = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'README.md'), 'readme') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), + } as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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 = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(joinPath(extensionDir, 'dist')) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'dist/a.js'), 'a') + await writeFile(joinPath(extensionDir, 'dist/b.js'), 'b') + await writeFile(joinPath(extensionDir, 'dist/c.js'), 'c') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), + } as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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, + await inTemporaryDirectory(async (tmpDir) => { + // Given + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(joinPath(extensionDir, 'public')) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'public/index.html'), 'html') + await writeFile(joinPath(extensionDir, 'public/logo.png'), 'logo') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), 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) + } as unknown as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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, mockContext) + + // 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, + await inTemporaryDirectory(async (tmpDir) => { + // Given — configuration has no static_root + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), 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() + } as unknown as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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, mockContext) + + // 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, + await inTemporaryDirectory(async (tmpDir) => { + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), 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`, - ) + } as unknown as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + const step: LifecycleStep = { + id: 'copy-static', + name: 'Copy Static', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', key: 'static_root'}], + }, + } + + await expect(executeIncludeAssetsStep(step, mockContext)).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, + await inTemporaryDirectory(async (tmpDir) => { + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), configuration: { targeting: [ { @@ -297,444 +472,648 @@ describe('executeIncludeAssetsStep', () => { }, ], }, - } 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'}], - }, - } + } as unknown as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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, mockContext)).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, + await inTemporaryDirectory(async (tmpDir) => { + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), 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 mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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, mockContext) + expect(result.filesCopied).toBe(0) + }) }) test('overwrites existing file on rebuild', async () => { - const contextWithConfig = { - ...mockContext, - extension: { - ...mockExtension, + await inTemporaryDirectory(async (tmpDir) => { + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'tools.json'), 'tools') + await writeFile(joinPath(outputDir, 'tools.json'), 'old-tools') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), configuration: {tools: './tools.json'}, - } as unknown as ExtensionInstance, - } + } as unknown as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + const step: LifecycleStep = { + id: 'copy-tools', + name: 'Copy Tools', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', key: 'tools'}], + }, + } - 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' + const result = await executeIncludeAssetsStep(step, mockContext) + + // Overwrites the existing file rather than creating tools-1.json + await expect(readFile(joinPath(outputDir, 'tools.json'))).resolves.toBe('tools') + 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, + await inTemporaryDirectory(async (tmpDir) => { + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(joinPath(extensionDir, 'a')) + await mkdir(joinPath(extensionDir, 'b')) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'a/schema.json'), 'schema-a') + await writeFile(joinPath(extensionDir, 'b/schema.json'), 'schema-b') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), configuration: {tools_a: './a/schema.json', tools_b: './b/schema.json'}, - } as unknown as ExtensionInstance, - } + } as unknown as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + const step: LifecycleStep = { + id: 'copy-tools', + name: 'Copy Tools', + type: 'include_assets', + config: { + inclusions: [ + {type: 'configKey', key: 'tools_a'}, + {type: 'configKey', key: 'tools_b'}, + ], + }, + } - vi.mocked(fs.fileExists).mockImplementation(async (path) => { - const pathStr = String(path) - return pathStr === '/test/extension/a/schema.json' || pathStr === '/test/extension/b/schema.json' + const result = await executeIncludeAssetsStep(step, mockContext) + + await expect(readFile(joinPath(outputDir, 'schema.json'))).resolves.toBe('schema-a') + await expect(readFile(joinPath(outputDir, 'schema-1.json'))).resolves.toBe('schema-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, + await inTemporaryDirectory(async (tmpDir) => { + // Given — static_root is an array + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(joinPath(extensionDir, 'public')) + await mkdir(joinPath(extensionDir, 'assets')) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'public/file1.html'), 'html1') + await writeFile(joinPath(extensionDir, 'assets/file2.html'), 'html2') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), 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') + } as unknown as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + const step: LifecycleStep = { + id: 'copy-static', + name: 'Copy Static', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', key: 'static_root'}], + }, + } + + // When + await executeIncludeAssetsStep(step, mockContext) + + // 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, + await inTemporaryDirectory(async (tmpDir) => { + // Given — TOML array-of-tables: extensions[].targeting[].tools + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + 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 mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), configuration: { extensions: [ {targeting: [{tools: 'tools-a.js'}, {tools: 'tools-b.js'}]}, {targeting: [{tools: 'tools-c.js'}]}, ], }, - } 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') + } as unknown as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + const step: LifecycleStep = { + id: 'copy-tools', + name: 'Copy Tools', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', key: 'extensions[].targeting[].tools'}], + }, + } + + // When + await executeIncludeAssetsStep(step, mockContext) + + // Then — all three tools paths resolved and copied (file paths → copyFile) + await expect(readFile(joinPath(outputDir, 'tools-a.js'))).resolves.toBe('a') + await expect(readFile(joinPath(outputDir, 'tools-b.js'))).resolves.toBe('b') + await expect(readFile(joinPath(outputDir, 'tools-c.js'))).resolves.toBe('c') + }) }) 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, + await inTemporaryDirectory(async (tmpDir) => { + // Given — targeting is a plain object, not an array + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), 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() + } as unknown as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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, mockContext) + + // 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, + await inTemporaryDirectory(async (tmpDir) => { + // Given + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(joinPath(extensionDir, 'public')) + await mkdir(joinPath(extensionDir, 'src')) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'public/index.html'), 'html') + await writeFile(joinPath(extensionDir, 'src/icon.png'), 'icon') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), 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) + } as unknown as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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, mockContext) + + // Then — directory configKey uses copyDirectoryContents; file static uses copyFile + 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 = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(joinPath(extensionDir, 'public')) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'public/logo.png'), 'logo') + await writeFile(joinPath(extensionDir, 'public/style.css'), 'css') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), + } as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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 = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'index.js'), 'js') + await writeFile(joinPath(extensionDir, 'manifest.json'), 'json') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), + } as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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 = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(joinPath(extensionDir, 'public')) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'public/logo.png'), 'logo') + await writeFile(joinPath(extensionDir, 'public/style.css'), 'css') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), + } as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + const step: LifecycleStep = { + id: 'copy-public', + name: 'Copy Public', + type: 'include_assets', + config: { + inclusions: [{type: 'pattern', baseDir: 'public', ignore: ['**/*.png']}], + }, + } + + // When + const result = await executeIncludeAssetsStep(step, mockContext) + + // Then + expect(result.filesCopied).toBe(1) + await expect(fileExists(joinPath(outputDir, 'style.css'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'logo.png'))).resolves.toBe(false) + }) }) 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 = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(joinPath(extensionDir, 'public')) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'public/logo.png'), 'logo') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), + } as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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 = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(joinPath(extensionDir, 'public')) + await mkdir(outputDir) + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), + } as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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, + await inTemporaryDirectory(async (tmpDir) => { + // Given + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(joinPath(extensionDir, 'theme')) + await mkdir(joinPath(extensionDir, 'assets')) + await mkdir(joinPath(extensionDir, 'src')) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'theme/index.html'), 'html') + await writeFile(joinPath(extensionDir, 'theme/style.css'), 'css') + await writeFile(joinPath(extensionDir, 'assets/logo.png'), 'png') + await writeFile(joinPath(extensionDir, 'assets/icon.svg'), 'svg') + await writeFile(joinPath(extensionDir, 'src/manifest.json'), 'json') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), 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') + } as unknown as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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, mockContext) + + // 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, + await inTemporaryDirectory(async (tmpDir) => { + // Given + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'tools.json'), 'tools') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), configuration: { extensions: [ { @@ -742,47 +1121,63 @@ describe('executeIncludeAssetsStep', () => { }, ], }, - } 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', - }, + } as unknown as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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, mockContext) + + // Then + 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, + await inTemporaryDirectory(async (tmpDir) => { + // Given + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'tools.json'), 'tools') + await writeFile(joinPath(extensionDir, 'instructions.md'), 'instructions') + await writeFile(joinPath(extensionDir, 'email-schema.json'), 'schema') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), configuration: { extensions: [ { @@ -798,60 +1193,78 @@ describe('executeIncludeAssetsStep', () => { }, ], }, - } 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'}], - }, + } as unknown as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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, mockContext) + + // Then — url is NOT in the manifest because no inclusion references it + 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, + await inTemporaryDirectory(async (tmpDir) => { + // Given + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'tools-a.js'), 'a') + await writeFile(joinPath(extensionDir, 'schema1.json'), 's1') + await writeFile(joinPath(extensionDir, 'tools-b.js'), 'b') + await writeFile(joinPath(extensionDir, 'schema2.json'), 's2') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), configuration: { extensions: [ { @@ -862,57 +1275,72 @@ describe('executeIncludeAssetsStep', () => { }, ], }, - } 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'}], - }, + } as unknown as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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, mockContext) + + // Then — two top-level keys, one per targeting entry + 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, + await inTemporaryDirectory(async (tmpDir) => { + // Given + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'tools.json'), 'tools') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), configuration: { extensions: [ { @@ -920,270 +1348,396 @@ describe('executeIncludeAssetsStep', () => { }, ], }, - } 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) - - // Then - expect(fs.writeFile).not.toHaveBeenCalled() + } as unknown as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + // 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, mockContext) + + // 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 = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(joinPath(extensionDir, 'public')) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'public/logo.png'), 'logo') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), + } as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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 + 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 — static file entry contributes its output path to the manifest "files" array + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(joinPath(extensionDir, 'src')) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'src/schema.json'), 'schema') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), + } as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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 + 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, + await inTemporaryDirectory(async (tmpDir) => { + // Given — configKey without anchor/groupBy contributes at manifest root + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'tools.json'), 'tools') + await writeFile(joinPath(extensionDir, 'instructions.md'), 'instructions') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), 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', + } as unknown as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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, mockContext) + + // Then — root-level keys use last path segment; values are output-relative paths + 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, + await inTemporaryDirectory(async (tmpDir) => { + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(joinPath(extensionDir, 'dist')) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'dist/index.html'), 'html') + await writeFile(joinPath(extensionDir, 'dist/style.css'), 'css') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), configuration: {admin: {static_root: 'dist'}}, - } as unknown as ExtensionInstance, - } + } as unknown as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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, mockContext) - 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' + // Then — directory produces a file list, not an opaque directory marker + const manifestContent = JSON.parse(await readFile(joinPath(outputDir, 'manifest.json'))) + expect(manifestContent).toEqual({static_root: expect.arrayContaining(['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 — inclusion has anchor but no groupBy — schema now rejects this at parse time + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), + } as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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', + ) + }) }) - 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, + test('merges manifest.json when it already exists in the output directory', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given — a prior inclusion already copied a manifest.json to the output dir + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'tools.json'), 'tools') + await writeFile(joinPath(outputDir, 'manifest.json'), '{ "old": "manifest" }') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), configuration: { extensions: [{targeting: [{target: 'admin.intent.link', tools: './tools.json'}]}], }, - } 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/') + } as unknown as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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 / Then — merges with existing manifest.json + await expect(executeIncludeAssetsStep(step, mockContext)).resolves.not.toThrow() + const manifestContent = JSON.parse(await readFile(joinPath(outputDir, 'manifest.json'))) + expect(manifestContent).toEqual({ + 'admin.intent.link': { + tools: 'tools.json', + }, + old: 'manifest', + }) }) - 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 / Then — overwrites existing manifest.json - await expect(executeIncludeAssetsStep(step, contextWithConfig)).resolves.not.toThrow() - expect(fs.writeFile).toHaveBeenCalledWith('/test/output/manifest.json', expect.any(String)) }) 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, + await inTemporaryDirectory(async (tmpDir) => { + // 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 extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), 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) - - // 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')) + } as unknown as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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, mockContext) + + // Then — no entries produced; manifest.json is NOT written, warning is logged + 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, + await inTemporaryDirectory(async (tmpDir) => { + // Given — one entry has a numeric target, the other has a valid string target + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'tools-bad.js'), 'bad') + await writeFile(joinPath(extensionDir, 'tools-good.js'), 'good') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), configuration: { extensions: [ { @@ -1194,172 +1748,226 @@ describe('executeIncludeAssetsStep', () => { }, ], }, - } 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'}, + } as unknown as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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, mockContext) + + // Then — only the string-keyed entry appears + const manifestContent = JSON.parse(await readFile(joinPath(outputDir, 'manifest.json'))) + expect(manifestContent).toEqual({ + 'admin.link': {tools: 'tools-good.js'}, + }) + expect(manifestContent).not.toHaveProperty('42') }) - 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', + await inTemporaryDirectory(async (tmpDir) => { + // Given — outputPath is a file, so outputDir is its dirname (/test/output) + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'tools.json'), 'tools') + + const mockExtension = { + directory: extensionDir, + 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) - - // 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') + } as unknown as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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, mockContext) + + // Then — manifest is placed under /test/output, which is dirname of extension.js + 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, + await inTemporaryDirectory(async (tmpDir) => { + // Given + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'tools.json'), 'tools') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), 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', - }, - ], - }, - } - - // 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 mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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, mockContext) + + // Then — file copying happened AND manifest was written + 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('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, + await inTemporaryDirectory(async (tmpDir) => { + // Given — config value is a bare filename with no ./ prefix; pathMap.has() must catch it + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + await writeFile(joinPath(extensionDir, 'tools.json'), 'tools') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), 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', - }, - ], - }, - } - - // 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 mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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, mockContext) + + // Then — 'tools.json' (no ./ prefix) must be resolved to its output-relative path in the manifest + 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, + await inTemporaryDirectory(async (tmpDir) => { + // Given — anchor === key, so stripAnchorPrefix returns "" and buildRelativeEntry returns the whole item + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), configuration: { extensions: [ { @@ -1367,83 +1975,104 @@ describe('executeIncludeAssetsStep', () => { }, ], }, - } 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', - }, + } as unknown as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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, mockContext) + + // 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. + 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, + await inTemporaryDirectory(async (tmpDir) => { + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + await mkdir(extensionDir) + await mkdir(outputDir) + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), 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 expect(executeIncludeAssetsStep(step, contextWithConfig)).rejects.toThrow( - `Couldn't find /test/extension/tools.json\n Please check the path './tools.json' in your configuration`, - ) + } as unknown as ExtensionInstance + + const mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {directory: tmpDir} as any, + environment: 'production' as 'production' | 'development', + }, + stepResults: new Map(), + } + + 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, mockContext)).rejects.toThrow( + `Couldn't find ${joinPath(extensionDir, 'tools.json')}\n Please check the path './tools.json' in your configuration`, + ) + }) }) }) }) diff --git a/packages/cli-kit/src/cli/api/graphql/admin/generated/types.d.ts b/packages/cli-kit/src/cli/api/graphql/admin/generated/types.d.ts index 2da991ce827..9ce2584b9d6 100644 --- a/packages/cli-kit/src/cli/api/graphql/admin/generated/types.d.ts +++ b/packages/cli-kit/src/cli/api/graphql/admin/generated/types.d.ts @@ -232,6 +232,8 @@ export type OnlineStoreThemeFilesUserErrorsCode = | 'LESS_THAN_OR_EQUAL_TO' /** The record with the ID used as the input value couldn't be found. */ | 'NOT_FOUND' + /** Theme contextualization and condition types are not compatible with each other. */ + | 'THEME_CONTEXTUALIZATION_NOT_COMPATIBLE_WITH_CONDITION_TYPES' /** There are theme files with conflicts. */ | 'THEME_FILES_CONFLICT' /** This action is not available on your current plan. Please upgrade to access theme editing features. */