diff --git a/tests/test_frontend_imports.mjs b/tests/test_frontend_imports.mjs new file mode 100644 index 0000000..92d401d --- /dev/null +++ b/tests/test_frontend_imports.mjs @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import { readdir, stat, readFile } from 'node:fs/promises'; +import { + dirname, + extname, + isAbsolute, + relative, + resolve, + sep, +} from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const WEB_ROOT = fileURLToPath(new URL('../web/', import.meta.url)); + +const STATIC_IMPORT_PATTERN = /(?:^|[;\r\n])\s*(?:import|export)\s+(?:[\s\S]*?\sfrom\s+)?["']([^"']+)["']/g; +const DYNAMIC_IMPORT_PATTERN = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g; + +async function collectJavaScriptFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const entryPath = resolve(directory, entry.name); + if (entry.isDirectory()) { + files.push(...await collectJavaScriptFiles(entryPath)); + } else if (entry.isFile() && extname(entry.name).toLowerCase() === '.js') { + files.push(entryPath); + } + } + + return files; +} + +function extractModuleSpecifiers(source) { + const specifiers = new Set(); + + for (const pattern of [STATIC_IMPORT_PATTERN, DYNAMIC_IMPORT_PATTERN]) { + pattern.lastIndex = 0; + for (const match of source.matchAll(pattern)) { + specifiers.add(match[1]); + } + } + + return [...specifiers]; +} + +function isInsideWebRoot(targetPath) { + const pathFromWebRoot = relative(WEB_ROOT, targetPath); + return Boolean( + pathFromWebRoot === '' + || (!pathFromWebRoot.startsWith(`..${sep}`) + && pathFromWebRoot !== '..' + && !isAbsolute(pathFromWebRoot)) + ); +} + +async function resolveLocalModule(importerPath, specifier) { + const targetPath = resolve(dirname(importerPath), specifier); + if (!isInsideWebRoot(targetPath)) { + // Imports outside web/ are ComfyUI-provided modules such as scripts/app.js. + return null; + } + + const candidates = [ + targetPath, + `${targetPath}.js`, + resolve(targetPath, 'index.js'), + ]; + + for (const candidate of candidates) { + try { + if ((await stat(candidate)).isFile()) return candidate; + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + } + + return targetPath; +} + +test('all local frontend module imports resolve to files', async () => { + const sourceFiles = await collectJavaScriptFiles(WEB_ROOT); + const missingImports = []; + + for (const importerPath of sourceFiles) { + const source = await readFile(importerPath, 'utf8'); + for (const specifier of extractModuleSpecifiers(source)) { + if (!specifier.startsWith('.')) continue; + + const resolvedPath = await resolveLocalModule(importerPath, specifier); + if (!resolvedPath) continue; + + try { + if ((await stat(resolvedPath)).isFile()) continue; + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + + missingImports.push({ + importer: relative(WEB_ROOT, importerPath), + specifier, + expected: relative(WEB_ROOT, resolvedPath), + }); + } + } + + assert.deepEqual(missingImports, [], 'local frontend imports must point to existing files'); +});