From 5afab2953dbfa4597e93b055656ce60993eb34c1 Mon Sep 17 00:00:00 2001 From: Nicholas Hart Date: Tue, 5 Aug 2025 09:22:56 -0700 Subject: [PATCH 1/3] detect changes to embedded UML and don't regenerate Mermaid images if unchanged --- src/shared/mermaid-processor.ts | 86 ++++++++++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 6 deletions(-) diff --git a/src/shared/mermaid-processor.ts b/src/shared/mermaid-processor.ts index db8ec6b..f3fc19e 100644 --- a/src/shared/mermaid-processor.ts +++ b/src/shared/mermaid-processor.ts @@ -202,9 +202,51 @@ export class MermaidProcessor { } } + /** + * Check if intermediate file content has changed + */ + private hasIntermediateContentChanged(intermediateFile: string, newContent: string): boolean { + if (!fs.existsSync(intermediateFile)) { + return true; // File doesn't exist, so content has "changed" + } + + const existingContent = fs.readFileSync(intermediateFile, 'utf8'); + return existingContent !== newContent; + } + + /** + * Check if image file needs regeneration based on timestamps + */ + private needsImageRegeneration(imageFile: string, intermediateFile: string): boolean { + if (!fs.existsSync(imageFile)) { + return true; // Image doesn't exist, need to generate + } + + if (!fs.existsSync(intermediateFile)) { + return true; // Source doesn't exist, need to generate + } + + try { + const imageStats = fs.statSync(imageFile); + const intermediateStats = fs.statSync(intermediateFile); + + // Handle mocked fs in tests where mtime might be undefined + if (!imageStats.mtime || !intermediateStats.mtime) { + return true; // Default to regenerating if we can't get timestamps + } + + // Regenerate if intermediate file is newer than image + return intermediateStats.mtime > imageStats.mtime; + } catch { + // If we can't get stats, default to regenerating + return true; + } + } + /** * Process markdown content by extracting Mermaid blocks, generating diagrams, - * and replacing blocks with image references including layout attributes + * and replacing blocks with image references including layout attributes. + * Implements caching based on content changes and file timestamps. */ async processMarkdown( markdown: string, @@ -248,15 +290,47 @@ export class MermaidProcessor { ? path.relative(intermediateDir, outputPath).replace(/\\/g, '/') : `assets/${outputFileName}`; - // Save intermediate Mermaid source file for debugging + let shouldGenerateImage = true; + + // Save intermediate Mermaid source file with caching logic if (intermediateDir) { const mermaidSourcePath = path.join(intermediateDir, `${block.name}.mmd`); - fs.writeFileSync(mermaidSourcePath, block.code); + + // Check if content has changed + const contentChanged = this.hasIntermediateContentChanged(mermaidSourcePath, block.code); + + if (contentChanged) { + // Content changed, update the intermediate file + fs.writeFileSync(mermaidSourcePath, block.code); + console.info(`📝 Updated intermediate file: ${block.name}.mmd`); + } else { + console.info(`⏭️ Skipped intermediate file (unchanged): ${block.name}.mmd`); + } + + // Check if image needs regeneration based on timestamps + shouldGenerateImage = this.needsImageRegeneration(outputPath, mermaidSourcePath); + + if (!shouldGenerateImage) { + console.info(`⚡ Skipped image generation (up to date): ${outputFileName}`); + } + } else { + // No intermediate directory, always generate + shouldGenerateImage = true; } - // Parse layout attributes from block attributes - const layoutOptions = this.parseLayoutAttributes(block.attributes); - const result = await this.generateDiagram(block.code, outputPath, layoutOptions); + let result; + if (shouldGenerateImage) { + // Parse layout attributes from block attributes + const layoutOptions = this.parseLayoutAttributes(block.attributes); + result = await this.generateDiagram(block.code, outputPath, layoutOptions); + + if (result.success) { + console.info(`🎨 Generated diagram: ${outputFileName}`); + } + } else { + // Skip generation, assume success since file exists + result = { success: true, outputPath }; + } if (result.success) { diagrams.push({ From a7f564171ab085129e4a3f5fef04b34238f52b53 Mon Sep 17 00:00:00 2001 From: Nicholas Hart Date: Tue, 5 Aug 2025 09:36:21 -0700 Subject: [PATCH 2/3] improve mermaid image quality and set some smart default fonts --- src/core/schemas.ts | 3 +++ src/shared/mermaid-processor.ts | 40 +++++++++++++++++++++++++++++++-- workflows/default-config.yml | 7 ++++-- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/core/schemas.ts b/src/core/schemas.ts index eff09e8..7a7bb16 100644 --- a/src/core/schemas.ts +++ b/src/core/schemas.ts @@ -127,6 +127,9 @@ export const SystemConfigSchema = z.object({ output_format: z.enum(['png', 'svg']), theme: z.enum(['default', 'dark', 'forest', 'neutral']).optional(), timeout: z.number(), + scale: z.number().optional(), + backgroundColor: z.string().optional(), + fontFamily: z.string().optional(), }) .optional(), testing: z diff --git a/src/shared/mermaid-processor.ts b/src/shared/mermaid-processor.ts index f3fc19e..0376525 100644 --- a/src/shared/mermaid-processor.ts +++ b/src/shared/mermaid-processor.ts @@ -16,6 +16,9 @@ export interface MermaidConfig { output_format: 'png' | 'svg'; theme?: 'default' | 'dark' | 'forest' | 'neutral'; timeout: number; + scale?: number; // Scale factor for high-DPI images (default: 2 for PNG, 1 for SVG) + backgroundColor?: string; // Background color (default: 'white') + fontFamily?: string; // Font family for SVG text (default: 'arial,sans-serif') } export interface DiagramGenerationResult { @@ -43,9 +46,12 @@ export class MermaidProcessor { output_format: 'png', theme: 'default', timeout: 30, + scale: 2, // 2x scale for crisp images in presentations + backgroundColor: 'white', + fontFamily: 'arial,sans-serif', // Web-safe font for SVG compatibility }; - const mermaidConfig = systemConfig.mermaid || defaultConfig; + const mermaidConfig = { ...defaultConfig, ...systemConfig.mermaid }; return new MermaidProcessor(mermaidConfig); } @@ -158,6 +164,8 @@ export class MermaidProcessor { // Build Mermaid CLI command const theme = this.config.theme || 'default'; const timeout = this.config.timeout * 1000; + const scale = this.config.scale || (this.config.output_format === 'png' ? 2 : 1); + const backgroundColor = this.config.backgroundColor || 'white'; // Add size constraints based on layout hints let sizeParams = ''; @@ -169,7 +177,35 @@ export class MermaidProcessor { sizeParams += ` -H ${height}`; } - const command = `npx @mermaid-js/mermaid-cli -i "${tempInputFile}" -o "${outputPath}" -t ${theme}${sizeParams}`; + // Add quality parameters + let qualityParams = ''; + qualityParams += ` -s ${scale}`; // Scale factor for high-DPI + qualityParams += ` -b ${backgroundColor}`; // Background color + + // Add SVG-specific font configuration + if (this.config.output_format === 'svg' && this.config.fontFamily) { + // Create a config file for SVG font settings + const configContent = JSON.stringify({ + fontFamily: this.config.fontFamily, + theme: { + primaryColor: '#000000', + primaryTextColor: '#000000', + fontFamily: this.config.fontFamily, + }, + }); + const configFile = path.join(tempDir, `mermaid-config-${Date.now()}.json`); + fs.writeFileSync(configFile, configContent); + qualityParams += ` -c "${configFile}"`; + + // Clean up config file after execution + setTimeout(() => { + if (fs.existsSync(configFile)) { + fs.unlinkSync(configFile); + } + }, 5000); + } + + const command = `npx @mermaid-js/mermaid-cli -i "${tempInputFile}" -o "${outputPath}" -t ${theme}${sizeParams}${qualityParams}`; execSync(command, { timeout, diff --git a/workflows/default-config.yml b/workflows/default-config.yml index 473b69f..39bd8e0 100644 --- a/workflows/default-config.yml +++ b/workflows/default-config.yml @@ -45,9 +45,12 @@ system: # Mermaid diagram generation settings mermaid: - output_format: "png" # Options: "png", "svg" - theme: "default" # Options: "default", "dark", "forest", "neutral" + output_format: "png" # Options: "png", "svg" + theme: "default" # Options: "default", "dark", "forest", "neutral" timeout: 30 + scale: 2 # Scale factor for high-DPI images (2x for crisp presentations) + backgroundColor: "white" # Background color for diagrams + fontFamily: "arial,sans-serif" # Web-safe font family for SVG text rendering # Testing overrides (normally disabled) testing: From c515e8a77abbc3326c6acd081a2089333450551a Mon Sep 17 00:00:00 2001 From: Nicholas Hart Date: Wed, 6 Aug 2025 14:04:27 -0700 Subject: [PATCH 3/3] removed useless mermaid layout "hint" functionality --- src/cli/shared/template-processor.ts | 13 ++- src/shared/mermaid-processor.ts | 80 +++----------- .../cli/shared/template-processor.test.ts | 104 ++++++++++++++++++ tests/unit/shared/mermaid-processor.test.ts | 96 +--------------- 4 files changed, 135 insertions(+), 158 deletions(-) diff --git a/src/cli/shared/template-processor.ts b/src/cli/shared/template-processor.ts index e802fed..74c5fe3 100644 --- a/src/cli/shared/template-processor.ts +++ b/src/cli/shared/template-processor.ts @@ -41,11 +41,18 @@ export class TemplateProcessor { collectionPath: string, options: TemplateProcessingOptions, ): Promise { - // Load user configuration if project paths are available + // Use the provided project config or load it if not available let userConfig = null; - if (options.projectPaths?.configFile && fs.existsSync(options.projectPaths.configFile)) { + if (options.projectConfig?.user) { + // Use the already loaded and merged project config + userConfig = options.projectConfig.user; + } else if (options.projectPaths?.configFile && fs.existsSync(options.projectPaths.configFile)) { + // Fallback: load config directly if not provided const configDiscovery = new ConfigDiscovery(); - const config = await configDiscovery.loadProjectConfig(options.projectPaths.configFile); + const config = await configDiscovery.loadProjectConfig( + options.projectPaths.configFile, + options.systemRoot, + ); userConfig = config?.user; } diff --git a/src/shared/mermaid-processor.ts b/src/shared/mermaid-processor.ts index 0376525..6e30047 100644 --- a/src/shared/mermaid-processor.ts +++ b/src/shared/mermaid-processor.ts @@ -7,7 +7,6 @@ import type { SystemConfig } from '../core/schemas.js'; export interface MermaidBlock { name: string; code: string; - attributes: string; // Layout attributes like "{align=center, width=80%}" startIndex: number; endIndex: number; } @@ -75,54 +74,21 @@ export class MermaidProcessor { } } - /** - * Parse layout attributes from attribute string - * Supports: {align=center, width=80%, layout=horizontal} - */ - private parseLayoutAttributes(attributeString: string): { - width?: number; - height?: number; - layout?: string; - } { - const options: { width?: number; height?: number; layout?: string } = {}; - - if (!attributeString) return options; - - // Remove braces and split by commas - const cleaned = attributeString.replace(/[{}]/g, '').trim(); - const attributes = cleaned.split(',').map((attr) => attr.trim()); - - for (const attr of attributes) { - const [key, value] = attr.split('=').map((s) => s.trim()); - - if (key === 'layout') { - options.layout = value; - } else if (key === 'width' && value.includes('px')) { - options.width = parseInt(value.replace('px', '')); - } else if (key === 'height' && value.includes('px')) { - options.height = parseInt(value.replace('px', '')); - } - } - - return options; - } - /** * Extract Mermaid blocks from markdown content - * Supports syntax: ```mermaid:diagram-name {align=center, width=80%, layout=horizontal} + * Supports syntax: ```mermaid:diagram-name */ extractMermaidBlocks(markdown: string): MermaidBlock[] { const blocks: MermaidBlock[] = []; - // Match mermaid:name with optional attributes code blocks - const regex = /```mermaid:([\w-]+)(\s*\{[^}]*\})?\s*\n([\s\S]*?)\n```/g; + // Match mermaid:name code blocks + const regex = /```mermaid:([\w-]+)\s*\n([\s\S]*?)\n```/g; let match; while ((match = regex.exec(markdown)) !== null) { blocks.push({ name: match[1], - attributes: match[2]?.trim() || '', // Optional attributes like {align=center, width=80%} - code: match[3], + code: match[2], startIndex: match.index, endIndex: match.index + match[0].length, }); @@ -134,11 +100,7 @@ export class MermaidProcessor { /** * Generate a diagram from Mermaid code using Mermaid CLI */ - async generateDiagram( - mermaidCode: string, - outputPath: string, - options?: { width?: number; height?: number; layout?: string }, - ): Promise { + async generateDiagram(mermaidCode: string, outputPath: string): Promise { const isAvailable = await MermaidProcessor.detectMermaidCLI(); if (!isAvailable) { @@ -167,34 +129,24 @@ export class MermaidProcessor { const scale = this.config.scale || (this.config.output_format === 'png' ? 2 : 1); const backgroundColor = this.config.backgroundColor || 'white'; - // Add size constraints based on layout hints - let sizeParams = ''; - if (options?.layout === 'horizontal' || options?.width) { - const width = options?.width || 1200; // Max width for horizontal layouts - sizeParams += ` -w ${width}`; - } else if (options?.layout === 'layered' || options?.height) { - const height = options?.height || 800; // Max height for vertical layouts - sizeParams += ` -H ${height}`; - } - // Add quality parameters let qualityParams = ''; qualityParams += ` -s ${scale}`; // Scale factor for high-DPI qualityParams += ` -b ${backgroundColor}`; // Background color - // Add SVG-specific font configuration + // Add configuration file for SVG fonts if needed if (this.config.output_format === 'svg' && this.config.fontFamily) { - // Create a config file for SVG font settings - const configContent = JSON.stringify({ + const configContent = { fontFamily: this.config.fontFamily, theme: { primaryColor: '#000000', primaryTextColor: '#000000', fontFamily: this.config.fontFamily, }, - }); + }; + const configFile = path.join(tempDir, `mermaid-config-${Date.now()}.json`); - fs.writeFileSync(configFile, configContent); + fs.writeFileSync(configFile, JSON.stringify(configContent)); qualityParams += ` -c "${configFile}"`; // Clean up config file after execution @@ -205,7 +157,7 @@ export class MermaidProcessor { }, 5000); } - const command = `npx @mermaid-js/mermaid-cli -i "${tempInputFile}" -o "${outputPath}" -t ${theme}${sizeParams}${qualityParams}`; + const command = `npx @mermaid-js/mermaid-cli -i "${tempInputFile}" -o "${outputPath}" -t ${theme}${qualityParams}`; execSync(command, { timeout, @@ -281,7 +233,7 @@ export class MermaidProcessor { /** * Process markdown content by extracting Mermaid blocks, generating diagrams, - * and replacing blocks with image references including layout attributes. + * and replacing blocks with image references. * Implements caching based on content changes and file timestamps. */ async processMarkdown( @@ -356,9 +308,7 @@ export class MermaidProcessor { let result; if (shouldGenerateImage) { - // Parse layout attributes from block attributes - const layoutOptions = this.parseLayoutAttributes(block.attributes); - result = await this.generateDiagram(block.code, outputPath, layoutOptions); + result = await this.generateDiagram(block.code, outputPath); if (result.success) { console.info(`🎨 Generated diagram: ${outputFileName}`); @@ -375,8 +325,8 @@ export class MermaidProcessor { relativePath, }); - // Replace Mermaid block with image reference including layout attributes - const imageMarkdown = `![${block.name}](${relativePath})${block.attributes}`; + // Replace Mermaid block with image reference + const imageMarkdown = `![${block.name}](${relativePath})`; processedMarkdown = processedMarkdown.slice(0, block.startIndex) + imageMarkdown + diff --git a/tests/unit/cli/shared/template-processor.test.ts b/tests/unit/cli/shared/template-processor.test.ts index 67b113d..3d15d88 100644 --- a/tests/unit/cli/shared/template-processor.test.ts +++ b/tests/unit/cli/shared/template-processor.test.ts @@ -434,6 +434,110 @@ describe('TemplateProcessor', () => { expect.stringMatching(/Created on: \w+, \w+ \d{1,2}, \d{4}/), ); }); + + it('should use provided project config instead of loading from file', async () => { + const providedConfig: ProjectConfig = { + user: { + name: 'Jane Smith', + preferred_name: 'jane smith', + email: 'jane@example.com', + phone: '(555) 987-6543', + address: '456 Oak Ave', + city: 'Test City', + state: 'TX', + zip: '54321', + linkedin: 'linkedin.com/in/janesmith', + github: 'github.com/janesmith', + website: 'janesmith.dev', + }, + system: { + scraper: 'wget', + web_download: { + timeout: 30, + add_utf8_bom: true, + html_cleanup: 'scripts', + }, + }, + }; + + mockFs.readFileSync.mockReturnValue('# {{user.name}} Resume\n\nEmail: {{user.email}}'); + + await TemplateProcessor.processTemplate(template, '/collection/path', { + systemRoot: '/system', + workflowName: 'job', + variables: { company: 'TestCorp', role: 'Engineer' }, + projectConfig: providedConfig, // Provide config directly + projectPaths: { + workflowsDir: '/project/.markdown-workflow/workflows', + configFile: '/project/config.yml', + }, + }); + + // Should use the provided config (Jane Smith) not load from file or use defaults + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + '/collection/path/resume_jane_smith.md', + expect.stringContaining('# Jane Smith Resume'), + ); + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('Email: jane@example.com'), + ); + + // Should NOT call loadProjectConfig since we provided the config + expect(mockConfigDiscovery.loadProjectConfig).not.toHaveBeenCalled(); + }); + + it('should fallback to loading config from file when not provided in options', async () => { + const fileConfig: ProjectConfig = { + user: { + name: 'Config From File', + preferred_name: 'config user', + email: 'file@example.com', + phone: '(555) 111-2222', + address: '789 Pine St', + city: 'File City', + state: 'FC', + zip: '11111', + linkedin: 'linkedin.com/in/fileuser', + github: 'github.com/fileuser', + website: 'fileuser.com', + }, + system: { + scraper: 'wget', + web_download: { + timeout: 30, + add_utf8_bom: true, + html_cleanup: 'scripts', + }, + }, + }; + + mockFs.readFileSync.mockReturnValue('# {{user.name}} Resume'); + mockConfigDiscovery.loadProjectConfig.mockResolvedValue(fileConfig); + + await TemplateProcessor.processTemplate(template, '/collection/path', { + systemRoot: '/system', + workflowName: 'job', + variables: { company: 'TestCorp', role: 'Engineer' }, + // No projectConfig provided - should load from file + projectPaths: { + workflowsDir: '/project/.markdown-workflow/workflows', + configFile: '/project/config.yml', + }, + }); + + // Should load config from file and pass systemRoot for proper merging + expect(mockConfigDiscovery.loadProjectConfig).toHaveBeenCalledWith( + '/project/config.yml', + '/system', // systemRoot should be passed for proper merging with defaults + ); + + // Should use the config loaded from file + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + '/collection/path/resume_config_user.md', + expect.stringContaining('# Config From File Resume'), + ); + }); }); describe('loadPartials', () => { diff --git a/tests/unit/shared/mermaid-processor.test.ts b/tests/unit/shared/mermaid-processor.test.ts index 97af5f5..e8dc16a 100644 --- a/tests/unit/shared/mermaid-processor.test.ts +++ b/tests/unit/shared/mermaid-processor.test.ts @@ -86,15 +86,14 @@ flowchart TD expect(blocks[0]).toEqual({ name: 'diagram-name', code: 'flowchart TD\n A --> B', - attributes: '', startIndex: expect.any(Number), endIndex: expect.any(Number), }); }); - it('should extract mermaid blocks with attributes', () => { + it('should extract mermaid blocks (attributes no longer supported)', () => { const markdown = ` -\`\`\`mermaid:solution-overview {align=center, width=80%, layout=horizontal} +\`\`\`mermaid:solution-overview flowchart LR A --> B --> C \`\`\` @@ -105,7 +104,6 @@ flowchart LR expect(blocks[0]).toEqual({ name: 'solution-overview', code: 'flowchart LR\n A --> B --> C', - attributes: '{align=center, width=80%, layout=horizontal}', startIndex: expect.any(Number), endIndex: expect.any(Number), }); @@ -120,7 +118,7 @@ flowchart TD Some text -\`\`\`mermaid:second-diagram {width=1000px} +\`\`\`mermaid:second-diagram graph LR X --> Y \`\`\` @@ -130,7 +128,7 @@ graph LR expect(blocks).toHaveLength(2); expect(blocks[0].name).toBe('first-diagram'); expect(blocks[1].name).toBe('second-diagram'); - expect(blocks[1].attributes).toBe('{width=1000px}'); + // Attributes no longer supported in simplified implementation }); it('should return empty array when no mermaid blocks found', () => { @@ -145,55 +143,6 @@ No mermaid diagrams here. }); }); - describe('layout attribute processing (tested through generateDiagram)', () => { - const mockMermaidCode = 'flowchart TD\n A --> B'; - const mockOutputPath = '/path/to/output.png'; - - beforeEach(() => { - mockExecSync.mockReturnValue(Buffer.from('10.6.1')); - }); - - it('should parse and apply horizontal layout attributes', async () => { - const options = { layout: 'horizontal', width: 1000 }; - await processor.generateDiagram(mockMermaidCode, mockOutputPath, options); - - expect(mockExecSync).toHaveBeenCalledWith( - expect.stringContaining('-w 1000'), - expect.any(Object), - ); - }); - - it('should parse and apply vertical layout attributes', async () => { - const options = { layout: 'layered', height: 800 }; - await processor.generateDiagram(mockMermaidCode, mockOutputPath, options); - - expect(mockExecSync).toHaveBeenCalledWith( - expect.stringContaining('-H 800'), - expect.any(Object), - ); - }); - - it('should handle custom width values', async () => { - const options = { width: 1500 }; - await processor.generateDiagram(mockMermaidCode, mockOutputPath, options); - - expect(mockExecSync).toHaveBeenCalledWith( - expect.stringContaining('-w 1500'), - expect.any(Object), - ); - }); - - it('should handle custom height values', async () => { - const options = { height: 600 }; - await processor.generateDiagram(mockMermaidCode, mockOutputPath, options); - - expect(mockExecSync).toHaveBeenCalledWith( - expect.stringContaining('-H 600'), - expect.any(Object), - ); - }); - }); - describe('detectMermaidCLI', () => { it('should return true when Mermaid CLI is available', async () => { mockExecSync.mockReturnValue(Buffer.from('10.6.1')); @@ -241,39 +190,6 @@ No mermaid diagrams here. ); }); - it('should generate diagram with horizontal layout constraints', async () => { - const options = { layout: 'horizontal', width: 1200 }; - const result = await processor.generateDiagram(mockMermaidCode, mockOutputPath, options); - - expect(result.success).toBe(true); - expect(mockExecSync).toHaveBeenCalledWith( - expect.stringContaining('-w 1200'), - expect.any(Object), - ); - }); - - it('should generate diagram with vertical layout constraints', async () => { - const options = { layout: 'layered', height: 800 }; - const result = await processor.generateDiagram(mockMermaidCode, mockOutputPath, options); - - expect(result.success).toBe(true); - expect(mockExecSync).toHaveBeenCalledWith( - expect.stringContaining('-H 800'), - expect.any(Object), - ); - }); - - it('should use default width for horizontal layout when not specified', async () => { - const options = { layout: 'horizontal' }; - const result = await processor.generateDiagram(mockMermaidCode, mockOutputPath, options); - - expect(result.success).toBe(true); - expect(mockExecSync).toHaveBeenCalledWith( - expect.stringContaining('-w 1200'), // default width - expect.any(Object), - ); - }); - it('should fail gracefully when Mermaid CLI is not available', async () => { mockExecSync.mockImplementation((cmd) => { if (cmd.toString().includes('--version')) { @@ -322,7 +238,7 @@ No mermaid diagrams here. const markdown = ` # Test Presentation -\`\`\`mermaid:solution-overview {layout=horizontal, width=1000px} +\`\`\`mermaid:solution-overview flowchart LR A --> B --> C \`\`\` @@ -340,7 +256,7 @@ Some description text. }); expect(result.processedMarkdown).toContain('![solution-overview]'); - expect(result.processedMarkdown).toContain('{layout=horizontal, width=1000px}'); + // Layout attributes no longer supported }); it('should return original markdown when no mermaid blocks found', async () => {