diff --git a/app/components/Workflow/DependencyGraph/CustomNodes/Code/CodeNode.js b/app/components/Workflow/DependencyGraph/CustomNodes/Code/CodeNode.js index e904903d..afd7e9c2 100644 --- a/app/components/Workflow/DependencyGraph/CustomNodes/Code/CodeNode.js +++ b/app/components/Workflow/DependencyGraph/CustomNodes/Code/CodeNode.js @@ -9,6 +9,7 @@ const ICON_TYPES = { R: `${ICON_PATH}r.svg`, SAS: `${ICON_PATH}sas.svg`, STATA: `${ICON_PATH}stata.png`, + JAVASCRIPT: `${ICON_PATH}js.svg`, CPP: `${ICON_PATH}cpp.svg`, GENERIC: `${ICON_PATH}generic.svg`, LIBRARY: `${ICON_PATH}library.svg`, @@ -35,6 +36,8 @@ function CodeNode({ node, renderType }) { iconUrl = ICON_TYPES.SAS; } else if (node.assetType === 'stata') { iconUrl = ICON_TYPES.STATA; + } else if (node.assetType === 'javascript') { + iconUrl = ICON_TYPES.JAVASCRIPT; } else if (node.assetType === 'cpp') { iconUrl = ICON_TYPES.CPP; } else if (node.assetType === 'rust') { diff --git a/app/components/Workflow/DependencyGraph/DependencyGraphEChart.js b/app/components/Workflow/DependencyGraph/DependencyGraphEChart.js index b48beab8..7e22a32a 100644 --- a/app/components/Workflow/DependencyGraph/DependencyGraphEChart.js +++ b/app/components/Workflow/DependencyGraph/DependencyGraphEChart.js @@ -15,6 +15,7 @@ const ICON_TYPES = { R: `${ICON_PATH}r.svg`, SAS: `${ICON_PATH}sas.svg`, STATA: `${ICON_PATH}stata.png`, + JAVASCRIPT: `${ICON_PATH}js.svg`, CPP: `${ICON_PATH}cpp.svg`, JAVA: `${ICON_PATH}java.svg`, GENERIC: `${ICON_PATH}generic.svg`, @@ -43,6 +44,8 @@ function getIcon(node) { iconUrl = ICON_TYPES.SAS; } else if (node.value === 'stata') { iconUrl = ICON_TYPES.STATA; + } else if (node.value === 'javascript') { + iconUrl = ICON_TYPES.JAVASCRIPT; } else if (node.value === 'cpp') { iconUrl = ICON_TYPES.CPP; } else if (node.value === 'java') { diff --git a/app/constants/assets-config.js b/app/constants/assets-config.js index e692866d..f95b5221 100644 --- a/app/constants/assets-config.js +++ b/app/constants/assets-config.js @@ -74,6 +74,11 @@ module.exports = { extensions: ['go'], categories: ['code'], }, + { + name: 'JavaScript', + extensions: ['js', 'jsx', 'mjs', 'cjs'], + categories: ['code'], + }, { name: 'C++', extensions: ['cc', 'cpp', 'cxx', 'c++', 'h', 'hh', 'hpp', 'hxx', 'ipp', 'tpp', 'inl'], diff --git a/app/images/js.svg b/app/images/js.svg new file mode 100644 index 00000000..f3a120d4 --- /dev/null +++ b/app/images/js.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/preload.js b/app/preload.js index ff09b64a..75f4f938 100644 --- a/app/preload.js +++ b/app/preload.js @@ -11,16 +11,17 @@ import PythonHandler from './services/assets/handlers/python'; import RHandler from './services/assets/handlers/r'; import SASHandler from './services/assets/handlers/sas'; import StataHandler from './services/assets/handlers/stata'; -import AssetUtil from './utils/asset'; -import ProjectUtil from './utils/project'; import JavaHandler from './services/assets/handlers/java'; import RustHandler from './services/assets/handlers/rust'; import GoHandler from './services/assets/handlers/go'; +import SQLHandler from './services/assets/handlers/sql'; +import JavaScriptHandler from './services/assets/handlers/javascript'; import CppHandler from './services/assets/handlers/cpp'; import CHandler from './services/assets/handlers/c'; -import Constants from './constants/constants'; -import SQLHandler from './services/assets/handlers/sql'; import DartHandler from './services/assets/handlers/dart'; +import AssetUtil from './utils/asset'; +import ProjectUtil from './utils/project'; +import Constants from './constants/constants'; const projectService = new ProjectService(); const projectListService = new ProjectListService(); @@ -63,6 +64,7 @@ contextBridge.exposeInMainWorld('workerElectronBridge', { new RustHandler(), new SQLHandler(), new GoHandler(), + new JavaScriptHandler(), new CppHandler(), new CHandler(), new DartHandler(), diff --git a/app/services/assets/handlers/javascript.js b/app/services/assets/handlers/javascript.js new file mode 100644 index 00000000..34568441 --- /dev/null +++ b/app/services/assets/handlers/javascript.js @@ -0,0 +1,182 @@ +import BaseCodeHandler from './baseCode'; +import Constants from '../../../constants/constants'; + +// JavaScript file extensions that we will scan. +// All lookups should be lowercase - we will do lowercase conversion before comparison. +const FILE_EXTENSION_LIST = ['js', 'jsx', 'mjs', 'cjs']; + +export default class JavaScriptHandler extends BaseCodeHandler { + static id = 'StatWrap.JavaScriptHandler'; + + constructor() { + super(JavaScriptHandler.id, FILE_EXTENSION_LIST); + } + + id() { + return JavaScriptHandler.id; + } + + getLibraryId(moduleName, importName) { + let id = ''; + if (moduleName && importName) { + id = `${moduleName}.${importName}`; + } else if (moduleName) { + id = moduleName; + } else if (importName) { + id = importName; + } else { + id = '(unknown)'; + } + return id; + } + + getInputs(uri, text) { + const inputs = []; + if (!text || text.trim() === '') { + return inputs; + } + + const processedPaths = new Set(); + + // fs.readFileSync('file') and fs.readFile('file', ...) + const readMatches = [ + ...text.matchAll(/fs\.(?:readFileSync|readFile)\s*\(\s*(['"]{1,}\s*?[\s\S]+?['"]{1,})[\s\S]*?\)/gim), + ]; + for (let index = 0; index < readMatches.length; index++) { + const match = readMatches[index]; + const filePath = match[1].trim(); + if (!processedPaths.has(filePath)) { + inputs.push({ + id: `readFile - ${filePath}`, + type: Constants.DependencyType.DATA, + path: filePath, + }); + processedPaths.add(filePath); + } + } + + // fs.createReadStream('file') + const streamMatches = [ + ...text.matchAll(/fs\.createReadStream\s*\(\s*(['"]{1,}\s*?[\s\S]+?['"]{1,})[\s\S]*?\)/gim), + ]; + for (let index = 0; index < streamMatches.length; index++) { + const match = streamMatches[index]; + const filePath = match[1].trim(); + if (!processedPaths.has(filePath)) { + inputs.push({ + id: `createReadStream - ${filePath}`, + type: Constants.DependencyType.DATA, + path: filePath, + }); + processedPaths.add(filePath); + } + } + + return inputs; + } + + getOutputs(uri, text) { + const outputs = []; + if (!text || text.trim() === '') { + return outputs; + } + + const processedPaths = new Set(); + + // fs.writeFileSync('file') and fs.writeFile('file', ...) + const writeMatches = [ + ...text.matchAll(/fs\.(?:writeFileSync|writeFile|appendFile|appendFileSync)\s*\(\s*(['"]{1,}\s*?[\s\S]+?['"]{1,})[\s\S]*?\)/gim), + ]; + for (let index = 0; index < writeMatches.length; index++) { + const match = writeMatches[index]; + const filePath = match[1].trim(); + if (!processedPaths.has(filePath)) { + outputs.push({ + id: `writeFile - ${filePath}`, + type: Constants.DependencyType.DATA, + path: filePath, + }); + processedPaths.add(filePath); + } + } + + // fs.createWriteStream('file') + const streamMatches = [ + ...text.matchAll(/fs\.createWriteStream\s*\(\s*(['"]{1,}\s*?[\s\S]+?['"]{1,})[\s\S]*?\)/gim), + ]; + for (let index = 0; index < streamMatches.length; index++) { + const match = streamMatches[index]; + const filePath = match[1].trim(); + if (!processedPaths.has(filePath)) { + outputs.push({ + id: `createWriteStream - ${filePath}`, + type: Constants.DependencyType.DATA, + path: filePath, + }); + processedPaths.add(filePath); + } + } + + return outputs; + } + + getLibraries(uri, text) { + const libraries = []; + if (!text || text.trim() === '') { + return libraries; + } + + // ES module imports: import x from 'module', import { x } from 'module', import * as x from 'module' + const esImportMatches = [ + ...text.matchAll(/^[^/]*?import\s+(?:(\*\s+as\s+\w+|\{[^}]*\}|\w+))\s+from\s+(['"]{1,}[\s\S]+?['"]{1,})/gim), + ]; + for (let index = 0; index < esImportMatches.length; index++) { + const match = esImportMatches[index]; + const importName = match[1].trim(); + const moduleName = match[2].trim().replace(/['"]/g, ''); + libraries.push({ + id: this.getLibraryId(moduleName, importName), + module: moduleName, + import: importName, + alias: null, + }); + } + + // Side-effect imports: import 'module' + const sideEffectMatches = [ + ...text.matchAll(/^[^/]*?import\s+(['"]{1,}[\s\S]+?['"]{1,})/gim), + ]; + for (let index = 0; index < sideEffectMatches.length; index++) { + const match = sideEffectMatches[index]; + const moduleName = match[1].trim().replace(/['"]/g, ''); + libraries.push({ + id: moduleName, + module: moduleName, + import: null, + alias: null, + }); + } + + // CommonJS require: require('module') or const x = require('module') + const requireMatches = [ + ...text.matchAll(/^[^/]*?(?:const|let|var)\s+(?:\{[^}]*\}|\w+)\s*=\s*require\s*\(\s*(['"]{1,}[\s\S]+?['"]{1,})\s*\)/gim), + ...text.matchAll(/^[^/]*?require\s*\(\s*(['"]{1,}[\s\S]+?['"]{1,})\s*\)/gim), + ]; + const seenModules = new Set(libraries.map((l) => l.module)); + for (let index = 0; index < requireMatches.length; index++) { + const match = requireMatches[index]; + const moduleName = match[1].trim().replace(/['"]/g, ''); + if (!seenModules.has(moduleName)) { + libraries.push({ + id: moduleName, + module: moduleName, + import: null, + alias: null, + }); + seenModules.add(moduleName); + } + } + + return libraries; + } +} diff --git a/app/utils/workflow.js b/app/utils/workflow.js index de99d30a..d13d1290 100644 --- a/app/utils/workflow.js +++ b/app/utils/workflow.js @@ -8,6 +8,7 @@ import JavaHandler from '../services/assets/handlers/java'; import RustHandler from '../services/assets/handlers/rust'; import SQLHandler from '../services/assets/handlers/sql'; import GoHandler from '../services/assets/handlers/go'; +import JavaScriptHandler from '../services/assets/handlers/javascript'; import CppHandler from '../services/assets/handlers/cpp'; import CHandler from '../services/assets/handlers/c'; import DartHandler from '../services/assets/handlers/dart'; @@ -66,6 +67,8 @@ export default class WorkflowUtil { assetType = 'sql'; } else if (AssetUtil.getHandlerMetadata(GoHandler.id, asset.metadata)) { assetType = 'go'; + } else if (AssetUtil.getHandlerMetadata(JavaScriptHandler.id, asset.metadata)) { + assetType = 'javascript'; } else if (AssetUtil.getHandlerMetadata(CHandler.id, asset.metadata)) { assetType = 'c'; } else if (AssetUtil.getHandlerMetadata(DartHandler.id, asset.metadata)) { @@ -371,6 +374,7 @@ export default class WorkflowUtil { WorkflowUtil._getMetadataDependencies(asset, RustHandler.id, libraries, inputs, outputs); WorkflowUtil._getMetadataDependencies(asset, SQLHandler.id, libraries, inputs, outputs); WorkflowUtil._getMetadataDependencies(asset, GoHandler.id, libraries, inputs, outputs); + WorkflowUtil._getMetadataDependencies(asset, JavaScriptHandler.id, libraries, inputs, outputs); WorkflowUtil._getMetadataDependencies(asset, CppHandler.id, libraries, inputs, outputs); WorkflowUtil._getMetadataDependencies(asset, CHandler.id, libraries, inputs, outputs); @@ -440,6 +444,7 @@ export default class WorkflowUtil { WorkflowUtil._getMetadataDependencies(asset, RustHandler.id, libraries, [], []); WorkflowUtil._getMetadataDependencies(asset, SQLHandler.id, libraries, [], []); WorkflowUtil._getMetadataDependencies(asset, GoHandler.id, libraries, [], []); + WorkflowUtil._getMetadataDependencies(asset, JavaScriptHandler.id, libraries, [], []); WorkflowUtil._getMetadataDependencies(asset, CppHandler.id, libraries, [], []); WorkflowUtil._getMetadataDependencies(asset, CHandler.id, libraries, [], []); WorkflowUtil._getMetadataDependencies(asset, DartHandler.id, libraries, [], []); diff --git a/docs/Assets.md b/docs/Assets.md index 88499a45..7e160dbb 100644 --- a/docs/Assets.md +++ b/docs/Assets.md @@ -179,6 +179,26 @@ We collect 3 categories of metadata for Stata code files: External programs and plugins, and references to Do files to run via another script. +### JavaScript Code Files + +We collect 3 categories of metadata for JavaScript code files (`.js`, `.jsx`, `.mjs`, `.cjs`): + +**Inputs** + +| Category | Example Functions / Patterns | Data Type | +| ------------------ | ------------------------------------------------------- | --------- | +| Node.js file reads | `fs.readFileSync`, `fs.readFile`, `fs.createReadStream` | `data` | + +**Outputs** + +| Category | Example Functions / Patterns | Data Type | +| ------------------- | ------------------------------------------------------------------------------------------------ | --------- | +| Node.js file writes | `fs.writeFileSync`, `fs.writeFile`, `fs.appendFile`, `fs.appendFileSync`, `fs.createWriteStream` | `data` | + +**Libraries** + +ES module imports (`import x from 'module'`, `import { x } from 'module'`, `import * as x from 'module'`, `import 'module'`) and CommonJS requires (`require('module')`). + ## Asset Groups By default StatWrap mimics the traditional hierarchical file system view. However, we realize that not all assets will be within a single file system (or may not even be files / folders). Also, we want to allow users to establish other groups of assets that make sense to them. Asset Groups will be a way for users to do this, and StatWrap will store these within the [Project](Projects.md) metadata. diff --git a/test/services/assets/asset.spec.js b/test/services/assets/asset.spec.js index ca2059b8..d76e648a 100644 --- a/test/services/assets/asset.spec.js +++ b/test/services/assets/asset.spec.js @@ -319,6 +319,24 @@ describe('services', () => { expect(service.assetContentTypes('folder.do', stat)).toStrictEqual(['other']); }); + it('should identify JavaScript code files as code', () => { + const stat = new fs.Stats(); + stat.isFile.mockReturnValue(true); + const service = new AssetService(); + expect(service.assetContentTypes('test.js', stat)).toStrictEqual(['code']); + expect(service.assetContentTypes('test.jsx', stat)).toStrictEqual(['code']); + expect(service.assetContentTypes('test.mjs', stat)).toStrictEqual(['code']); + expect(service.assetContentTypes('test.cjs', stat)).toStrictEqual(['code']); + expect(service.assetContentTypes('test.JS', stat)).toStrictEqual(['code']); + expect(service.assetContentTypes('test.JSX', stat)).toStrictEqual(['code']); + // False leads... + expect(service.assetContentTypes('test.ts', stat)).toStrictEqual(['other']); + expect(service.assetContentTypes('test.js.bak', stat)).toStrictEqual(['other']); + expect(service.assetContentTypes('.js', stat)).toStrictEqual(['other']); + stat.isFile.mockReturnValue(false); + expect(service.assetContentTypes('folder.js', stat)).toStrictEqual(['other']); + }); + it('should identify Stata data files as data', () => { const stat = new fs.Stats(); stat.isFile.mockReturnValue(true); diff --git a/test/services/assets/handlers/javascript.spec.js b/test/services/assets/handlers/javascript.spec.js new file mode 100644 index 00000000..df6518c1 --- /dev/null +++ b/test/services/assets/handlers/javascript.spec.js @@ -0,0 +1,349 @@ +import fs from 'fs'; +import JavaScriptHandler from '../../../../app/services/assets/handlers/javascript'; +import Constants from '../../../../app/constants/constants'; + +jest.mock('fs'); + +describe('services', () => { + describe('JavaScriptHandler', () => { + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + describe('id', () => { + it('should return an id that matches the class name plus StatWrap pseudo-namespace', () => { + expect(new JavaScriptHandler().id()).toEqual(`StatWrap.${JavaScriptHandler.name}`); + }); + }); + + describe('includeFile', () => { + it('should include JS files and exclude others', () => { + const handler = new JavaScriptHandler(); + expect(handler.includeFile('/path/to/app.js')).toBeTruthy(); + expect(handler.includeFile('/path/to/component.jsx')).toBeTruthy(); + expect(handler.includeFile('/path/to/module.mjs')).toBeTruthy(); + expect(handler.includeFile('/path/to/module.cjs')).toBeTruthy(); + expect(handler.includeFile('/path/to/app.ts')).toBeFalsy(); + expect(handler.includeFile('/path/to/app.py')).toBeFalsy(); + expect(handler.includeFile(null)).toBeFalsy(); + }); + }); + + describe('getLibraries', () => { + it('should return empty array for empty text', () => { + expect(new JavaScriptHandler().getLibraries('test.uri', '')).toEqual([]); + expect(new JavaScriptHandler().getLibraries('test.uri', null)).toEqual([]); + }); + + it('should detect default ES module imports', () => { + const libraries = new JavaScriptHandler().getLibraries( + 'test.uri', + "import React from 'react';" + ); + expect(libraries.length).toEqual(1); + expect(libraries[0]).toMatchObject({ + module: 'react', + import: 'React', + }); + }); + + it('should detect named ES module imports', () => { + const libraries = new JavaScriptHandler().getLibraries( + 'test.uri', + "import { readFileSync } from 'fs';" + ); + expect(libraries.length).toEqual(1); + expect(libraries[0]).toMatchObject({ + module: 'fs', + import: '{ readFileSync }', + }); + }); + + it('should detect namespace ES module imports', () => { + const libraries = new JavaScriptHandler().getLibraries( + 'test.uri', + "import * as path from 'path';" + ); + expect(libraries.length).toEqual(1); + expect(libraries[0]).toMatchObject({ + module: 'path', + }); + }); + + it('should detect CommonJS require', () => { + const libraries = new JavaScriptHandler().getLibraries( + 'test.uri', + "const fs = require('fs');" + ); + expect(libraries.length).toEqual(1); + expect(libraries[0]).toMatchObject({ + module: 'fs', + }); + }); + + it('should detect destructured require', () => { + const libraries = new JavaScriptHandler().getLibraries( + 'test.uri', + "const { join, resolve } = require('path');" + ); + expect(libraries.length).toEqual(1); + expect(libraries[0]).toMatchObject({ + module: 'path', + }); + }); + + it('should detect ES module imports using double quotes', () => { + const libraries = new JavaScriptHandler().getLibraries( + 'test.uri', + 'import { readFile, writeFile } from "fs/promises";' + ); + expect(libraries.length).toEqual(1); + expect(libraries[0]).toMatchObject({ + module: 'fs/promises', + import: '{ readFile, writeFile }', + }); + }); + + it('should detect CommonJS require using double quotes', () => { + const libraries = new JavaScriptHandler().getLibraries( + 'test.uri', + 'const path = require("path");' + ); + expect(libraries.length).toEqual(1); + expect(libraries[0]).toMatchObject({ + module: 'path', + }); + }); + + it('should not detect commented-out imports', () => { + const libraries = new JavaScriptHandler().getLibraries( + 'test.uri', + "// import React from 'react';\n// const fs = require('fs');" + ); + expect(libraries.length).toEqual(0); + }); + }); + + describe('getInputs', () => { + it('should return empty array for empty text', () => { + expect(new JavaScriptHandler().getInputs('test.uri', '')).toEqual([]); + expect(new JavaScriptHandler().getInputs('test.uri', null)).toEqual([]); + }); + + it('should detect fs.readFileSync', () => { + const inputs = new JavaScriptHandler().getInputs( + 'test.uri', + "const data = fs.readFileSync('input.csv', 'utf8');" + ); + expect(inputs.length).toEqual(1); + expect(inputs[0]).toMatchObject({ + id: "readFile - 'input.csv'", + type: Constants.DependencyType.DATA, + path: "'input.csv'", + }); + }); + + it('should detect fs.readFile', () => { + const inputs = new JavaScriptHandler().getInputs( + 'test.uri', + "fs.readFile('data.json', callback);" + ); + expect(inputs.length).toEqual(1); + expect(inputs[0]).toMatchObject({ + type: Constants.DependencyType.DATA, + }); + }); + + it('should detect fs.createReadStream', () => { + const inputs = new JavaScriptHandler().getInputs( + 'test.uri', + "const stream = fs.createReadStream('input.csv');" + ); + expect(inputs.length).toEqual(1); + expect(inputs[0]).toMatchObject({ + id: "createReadStream - 'input.csv'", + type: Constants.DependencyType.DATA, + }); + }); + + it('should not duplicate the same path', () => { + const inputs = new JavaScriptHandler().getInputs( + 'test.uri', + "fs.readFileSync('data.csv');\nfs.readFileSync('data.csv');" + ); + expect(inputs.length).toEqual(1); + }); + + it('should detect paths using double quotes', () => { + const inputs = new JavaScriptHandler().getInputs( + 'test.uri', + 'const data = fs.readFileSync("input.csv", "utf8");' + ); + expect(inputs.length).toEqual(1); + expect(inputs[0]).toMatchObject({ + id: 'readFile - "input.csv"', + type: Constants.DependencyType.DATA, + path: '"input.csv"', + }); + }); + + it('should detect fully qualified Linux/Mac paths', () => { + const inputs = new JavaScriptHandler().getInputs( + 'test.uri', + "fs.readFileSync('/home/user/data/test.csv', 'utf8');" + ); + expect(inputs.length).toEqual(1); + expect(inputs[0]).toMatchObject({ + type: Constants.DependencyType.DATA, + path: "'/home/user/data/test.csv'", + }); + }); + + it('should detect fully qualified Windows paths', () => { + const inputs = new JavaScriptHandler().getInputs( + 'test.uri', + "fs.readFileSync('C:\\Users\\user\\data\\test.csv', 'utf8');" + ); + expect(inputs.length).toEqual(1); + expect(inputs[0]).toMatchObject({ + type: Constants.DependencyType.DATA, + }); + }); + + it('should detect relative paths', () => { + const inputs = new JavaScriptHandler().getInputs( + 'test.uri', + "fs.readFileSync('data/test.csv');\nfs.readFileSync('../data/test.csv');" + ); + expect(inputs.length).toEqual(2); + expect(inputs[0]).toMatchObject({ path: "'data/test.csv'" }); + expect(inputs[1]).toMatchObject({ path: "'../data/test.csv'" }); + }); + }); + + describe('getOutputs', () => { + it('should return empty array for empty text', () => { + expect(new JavaScriptHandler().getOutputs('test.uri', '')).toEqual([]); + expect(new JavaScriptHandler().getOutputs('test.uri', null)).toEqual([]); + }); + + it('should detect fs.writeFileSync', () => { + const outputs = new JavaScriptHandler().getOutputs( + 'test.uri', + "fs.writeFileSync('output.csv', data);" + ); + expect(outputs.length).toEqual(1); + expect(outputs[0]).toMatchObject({ + id: "writeFile - 'output.csv'", + type: Constants.DependencyType.DATA, + path: "'output.csv'", + }); + }); + + it('should detect fs.writeFile', () => { + const outputs = new JavaScriptHandler().getOutputs( + 'test.uri', + "fs.writeFile('result.json', data, callback);" + ); + expect(outputs.length).toEqual(1); + expect(outputs[0]).toMatchObject({ + type: Constants.DependencyType.DATA, + }); + }); + + it('should detect fs.appendFile and fs.appendFileSync', () => { + const outputs = new JavaScriptHandler().getOutputs( + 'test.uri', + "fs.appendFile('log.txt', entry, callback);\nfs.appendFileSync('log2.txt', entry);" + ); + expect(outputs.length).toEqual(2); + }); + + it('should detect fs.createWriteStream', () => { + const outputs = new JavaScriptHandler().getOutputs( + 'test.uri', + "const stream = fs.createWriteStream('output.csv');" + ); + expect(outputs.length).toEqual(1); + expect(outputs[0]).toMatchObject({ + id: "createWriteStream - 'output.csv'", + type: Constants.DependencyType.DATA, + }); + }); + + it('should not duplicate the same path', () => { + const outputs = new JavaScriptHandler().getOutputs( + 'test.uri', + "fs.writeFileSync('out.csv', data);\nfs.writeFileSync('out.csv', data);" + ); + expect(outputs.length).toEqual(1); + }); + + it('should detect paths using double quotes', () => { + const outputs = new JavaScriptHandler().getOutputs( + 'test.uri', + 'fs.writeFileSync("output.csv", data);' + ); + expect(outputs.length).toEqual(1); + expect(outputs[0]).toMatchObject({ + id: 'writeFile - "output.csv"', + type: Constants.DependencyType.DATA, + path: '"output.csv"', + }); + }); + + it('should detect fully qualified Linux/Mac paths', () => { + const outputs = new JavaScriptHandler().getOutputs( + 'test.uri', + "fs.writeFileSync('/home/user/results/output.csv', data);" + ); + expect(outputs.length).toEqual(1); + expect(outputs[0]).toMatchObject({ + type: Constants.DependencyType.DATA, + path: "'/home/user/results/output.csv'", + }); + }); + + it('should detect fully qualified Windows paths', () => { + const outputs = new JavaScriptHandler().getOutputs( + 'test.uri', + "fs.writeFileSync('C:\\Users\\user\\results\\output.csv', data);" + ); + expect(outputs.length).toEqual(1); + expect(outputs[0]).toMatchObject({ + type: Constants.DependencyType.DATA, + }); + }); + + it('should detect relative paths', () => { + const outputs = new JavaScriptHandler().getOutputs( + 'test.uri', + "fs.writeFileSync('results/output.csv', data);\nfs.writeFileSync('../results/output.csv', data);" + ); + expect(outputs.length).toEqual(2); + expect(outputs[0]).toMatchObject({ path: "'results/output.csv'" }); + expect(outputs[1]).toMatchObject({ path: "'../results/output.csv'" }); + }); + }); + + describe('scan', () => { + it('should return metadata for a valid JS file', () => { + fs.readFileSync.mockReturnValue( + "const fs = require('fs');\nconst data = fs.readFileSync('input.csv', 'utf8');" + ); + const testAsset = { + uri: '/path/to/script.js', + type: 'file', + metadata: [], + }; + const response = new JavaScriptHandler().scan(testAsset); + expect(response.metadata[0]).toMatchObject({ + id: 'StatWrap.JavaScriptHandler', + libraries: [{ module: 'fs' }], + inputs: [{ type: Constants.DependencyType.DATA }], + }); + }); + }); + }); +}); diff --git a/test/utils/workflow.spec.js b/test/utils/workflow.spec.js index 652e4e90..f68c617e 100644 --- a/test/utils/workflow.spec.js +++ b/test/utils/workflow.spec.js @@ -81,6 +81,44 @@ describe('utils', () => { expect(dependencies[2].dependencies.length).toEqual(1); expect(dependencies[3].dependencies.length).toEqual(1); }); + + it('should include JavaScript metadata dependencies in the workflow list', () => { + const asset = { + uri: '/test/js', + metadata: [ + { + id: 'StatWrap.JavaScriptHandler', + libraries: [ + { + id: 'react', + module: 'react', + import: 'React', + alias: null, + }, + ], + inputs: [ + { + id: 'readFile - "data/input.csv"', + type: Constants.DependencyType.DATA, + path: '"data/input.csv"', + }, + ], + outputs: [ + { + id: 'writeFile - "data/output.csv"', + type: Constants.DependencyType.DATA, + path: '"data/output.csv"', + }, + ], + }, + ], + children: [], + }; + const dependencies = WorkflowUtil.getAllDependencies(asset); + expect(dependencies.length).toEqual(1); + expect(dependencies[0].assetType).toEqual('javascript'); + expect(dependencies[0].dependencies.length).toEqual(3); + }); }); describe('getAllDependenciesAsGraph', () => { @@ -568,6 +606,9 @@ describe('utils', () => { expect(WorkflowUtil.getAssetType({ metadata: [{ id: 'StatWrap.PythonHandler' }] })).toEqual( 'python', ); + expect(WorkflowUtil.getAssetType({ metadata: [{ id: 'StatWrap.JavaScriptHandler' }] })).toEqual( + 'javascript', + ); }); it('should return a default value for unkown types', () => {